snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
All checks were successful
Release / Create Version PR or Publish Stable Release (push) Has been skipped
Release / Publish Prerelease to NPM (push) Has been skipped
Monitor Registries / Monitor registry health (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 22:44:43 +05:00
commit 1416db152c
5727 changed files with 630238 additions and 0 deletions

2
packages/helpers/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
.turbo

View File

@@ -0,0 +1,15 @@
# @shadcn/helpers
## 0.2.0
### Minor Changes
- [#11484](https://github.com/shadcn-ui/ui/pull/11484) [`401d10b8180b17eb1fdd36d537abaa8aeb68708f`](https://github.com/shadcn-ui/ui/commit/401d10b8180b17eb1fdd36d537abaa8aeb68708f) Thanks [@shadcn](https://github.com/shadcn)! - Add human-in-the-loop mocking for the AI SDK.
- [#11484](https://github.com/shadcn-ui/ui/pull/11484) [`401d10b8180b17eb1fdd36d537abaa8aeb68708f`](https://github.com/shadcn-ui/ui/commit/401d10b8180b17eb1fdd36d537abaa8aeb68708f) Thanks [@shadcn](https://github.com/shadcn)! - Make `createChat` generic over the UI message type, like `useChat`.
## 0.1.0
### Minor Changes
- [#11168](https://github.com/shadcn-ui/ui/pull/11168) [`0ec4a7ffbf21af08ed31b721696614533aa591c7`](https://github.com/shadcn-ui/ui/commit/0ec4a7ffbf21af08ed31b721696614533aa591c7) Thanks [@shadcn](https://github.com/shadcn)! - initial release of @shadcn/helpers

View File

@@ -0,0 +1,94 @@
# @shadcn/helpers
A collection of helpers for building AI applications.
## createChat
```ts
import { createChat } from "@shadcn/helpers/ai-sdk"
const chat = createChat()
.user("What's the weather in San Francisco?")
.sleep(800)
.assistant(({ writer }) => {
writer.reasoning("I should call the weather tool first.")
writer
.tool("getWeather", { input: { city: "San Francisco" } })
.sleep(1200)
.output({ temperature: 72, condition: "sunny" })
writer.text("It's sunny and 72 degrees in San Francisco.")
})
```
## Human in the loop
A tool call left unresolved pauses the turn: the input streams, the turn
finishes, and the client decides what happens next. A callback turn scripted
after a paused turn becomes a continuation. It materializes when the follow-up
request arrives and receives the resolved `toolCall` in its context.
For client-executed tools, the user supplies the output through
`addToolOutput` and the continuation reads it:
```ts
const chat = createChat()
.user("Help me plan the release.")
.assistant(({ writer }) => {
writer.tool("askQuestions", { dynamic: true, input: { questions } })
})
.assistant(({ writer, toolCall }) => {
writer.text(`Got it. Starting with ${toolCall?.output?.answers.direction}.`)
})
```
For approval-gated tools, `needsApproval` pauses behind the user's decision.
`output` (or `errorText`) then means "stream this if approved"; denial streams
`tool-output-denied` automatically:
```ts
const chat = createChat()
.user("Clean up old deployments.")
.assistant(({ writer }) => {
writer.text("This will delete 3 deployments. Approve?")
writer.tool("deleteDeployments", {
input: { count: 3 },
needsApproval: true,
output: { deleted: 3 },
})
})
.assistant(({ writer, toolCall }) => {
writer.text(
toolCall?.approved
? "Deleted 3 deployments."
: "Okay, leaving them in place."
)
})
```
Wire the client with the AI SDK's own triggers:
`sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` for
client-executed tools, or `lastAssistantMessageIsCompleteWithApprovalResponses`
plus `addToolApprovalResponse({ id: part.approval.id, approved })` for
approvals.
Continuation callbacks must be pure; regenerating re-resolves them against the
current transcript. `get()` stops before the first continuation turn, since a
continuation has no message without a live transcript.
## Installation
```bash
npm install @shadcn/helpers
```
## Documentation
Visit https://ui.shadcn.com/docs to view the documentation.
## Contributing
Please read the [contributing guide](https://github.com/shadcn-ui/ui/blob/main/CONTRIBUTING.md).
## License
Licensed under the [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).

View File

@@ -0,0 +1,81 @@
{
"name": "@shadcn/helpers",
"version": "0.2.0",
"description": "Small helpers for developing apps.",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"author": {
"name": "shadcn",
"url": "https://twitter.com/shadcn"
},
"repository": {
"type": "git",
"url": "https://github.com/shadcn-ui/ui.git",
"directory": "packages/helpers"
},
"homepage": "https://ui.shadcn.com",
"bugs": {
"url": "https://github.com/shadcn-ui/ui/issues"
},
"files": [
"dist"
],
"keywords": [
"ai",
"chat",
"helpers",
"development",
"ai-sdk",
"tanstack",
"shadcn"
],
"type": "module",
"sideEffects": false,
"exports": {
"./ai-sdk": {
"types": "./dist/ai-sdk/index.d.ts",
"default": "./dist/ai-sdk/index.js"
},
"./tanstack-ai": {
"types": "./dist/tanstack-ai/index.d.ts",
"default": "./dist/tanstack-ai/index.js"
}
},
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"typecheck": "tsc --noEmit",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"clean": "rimraf dist",
"test": "vitest run"
},
"peerDependencies": {
"@tanstack/ai": ">=0.40.0 <0.41.0",
"@tanstack/ai-client": ">=0.20.0 <0.21.0",
"ai": ">=7.0.0 <8"
},
"peerDependenciesMeta": {
"@tanstack/ai": {
"optional": true
},
"@tanstack/ai-client": {
"optional": true
},
"ai": {
"optional": true
}
},
"devDependencies": {
"@tanstack/ai": "0.40.0",
"@tanstack/ai-client": "0.20.0",
"ai": "7.0.0-canary.159",
"zod": "3.25.76",
"rimraf": "^6.0.1",
"tsup": "^8.5.0",
"typescript": "^5.9.2",
"vitest": "^3.2.6"
}
}

View File

@@ -0,0 +1,390 @@
import type { UIMessage } from "ai"
import { describe, expect, expectTypeOf, it } from "vitest"
import { testChatContract } from "../test/chat-contract"
import { createChat } from "./index"
import { getMessageText } from "./parts"
import {
artifact,
weatherLoading,
weatherOutput,
weatherSuccess,
} from "./test-utils"
import type { DataParts, TestMessage, Tools } from "./test-utils"
testChatContract("AI SDK", () =>
createChat().user("One").assistant("A").user("Two").assistant("B")
)
describe("AI SDK chat", () => {
it("narrows the continuation toolCall by name", () => {
createChat<TestMessage>()
.user("Weather?")
.assistant(({ writer }) => {
writer.tool("getWeather", { input: { city: "San Francisco" } })
})
.assistant(({ toolCall }) => {
if (toolCall?.name === "getWeather") {
expectTypeOf(toolCall.input).toEqualTypeOf<
Tools["getWeather"]["input"]
>()
expectTypeOf(toolCall.output).toEqualTypeOf<
Tools["getWeather"]["output"] | undefined
>()
}
})
})
it("preserves the native message type through get and next", () => {
type Metadata = { model: string }
const chat = createChat<UIMessage<Metadata, DataParts, Tools>>()
.user("Weather?", { metadata: { model: "test" } })
.assistant("Sunny.", { metadata: { model: "test" } })
const messages = chat.get()
const next = chat.next([])
expectTypeOf(messages).toEqualTypeOf<
Array<UIMessage<Metadata, DataParts, Tools>>
>()
expectTypeOf(next).toEqualTypeOf<UIMessage<
Metadata,
DataParts,
Tools
> | null>()
})
it("creates chat fixture user messages with attachments", () => {
const chat = createChat().user("Analyze this PDF and summarize it.", {
files: [
{
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
},
],
})
const [message] = chat.get()
expect(message).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "Analyze this PDF and summarize it.",
},
{
type: "file",
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
},
],
})
})
it("creates a default chat fixture with createChat", () => {
const chat = createChat().user("Hello").assistant("Hey.")
expect(chat.get().map((message) => message.role)).toEqual([
"user",
"assistant",
])
})
it("gets text from chat fixture messages", () => {
const chat = createChat()
.user("Hello")
.assistant(({ writer }) => {
writer.reasoning("Reasoning should not be included.")
writer.text("Hey, ")
writer.text("how's it going?")
})
.user("What's next?")
const messages = chat.get(2)
const [, assistantMessage] = messages
const nextMessage = chat.next(messages)
expect(messages.map(getMessageText).join("\n")).toBe(
"Hello\nHey, how's it going?"
)
expect(getMessageText(assistantMessage)).toBe("Hey, how's it going?")
expect(nextMessage ? getMessageText(nextMessage) : "").toBe("What's next?")
})
it("supports assistant ids and metadata in chat fixtures", () => {
const chat = createChat<UIMessage<{ model: string }>>()
.user("Hello")
.assistant("Hey.", {
id: "assistant-1",
metadata: {
model: "gpt-5",
},
})
const [, assistantMessage] = chat.get()
expect(assistantMessage).toMatchObject({
id: "assistant-1",
role: "assistant",
metadata: {
model: "gpt-5",
},
})
})
it("creates arbitrary data and tool parts with createChat", () => {
const chat = createChat().assistant(({ writer }) => {
writer.data({
type: "data-invoice",
id: "invoice-1",
data: {
id: "inv-1",
status: "paid",
},
})
writer
.tool("lookupInvoice", {
input: {
id: "inv-1",
},
})
.output({
id: "inv-1",
total: 128,
})
})
const [message] = chat.get()
expect(message.parts).toMatchObject([
{
type: "data-invoice",
id: "invoice-1",
data: {
status: "paid",
},
},
{
type: "tool-lookupInvoice",
state: "output-available",
input: {
id: "inv-1",
},
output: {
total: 128,
},
},
])
})
it("returns top messages and then the next scripted message", () => {
const chat = createChat()
.user("Hello")
.assistant("Hey, how's it going?")
.user("What's the weather?")
.assistant("It's sunny.")
const initialMessages = chat.get(2)
const nextMessage = chat.next(initialMessages)
expect(initialMessages.map((message) => message.role)).toEqual([
"user",
"assistant",
])
expect(nextMessage).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "What's the weather?",
},
],
})
})
it("does not mutate playback when next is called again", () => {
const chat = createChat()
.user("Hello")
.assistant("Hey.")
.user("What's the weather?")
.assistant("It's sunny.")
.user("What time is it?")
.assistant("It is noon.")
const initialMessages = chat.get(2)
const first = chat.next(initialMessages)
const second = chat.next(initialMessages)
expect(first).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "What's the weather?",
},
],
})
expect(second).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "What's the weather?",
},
],
})
})
it("finds the next user message from a transcript", () => {
const chat = createChat()
.user("Hello")
.assistant("Hey.")
.user("What's the weather?")
.assistant("It's sunny.")
const [firstMessage] = chat.get(1)
const nextMessage = chat.next([firstMessage])
expect(nextMessage).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "What's the weather?",
},
],
})
})
it("materializes assistant writer parts into the final chat transcript", () => {
const chat = createChat<TestMessage>().assistant(({ writer }) => {
writer.reasoning("I need to call the weather tool.")
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherLoading,
})
const weather = writer.tool("getWeather", {
title: "Checking weather",
input: {
city: "San Francisco",
},
})
writer.sleep(100)
weather.output(weatherOutput)
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherSuccess,
})
writer.text("It's sunny and 72 degrees in San Francisco.")
})
const [message] = chat.get()
expect(message.parts.map((part) => part.type)).toEqual([
"reasoning",
"data-weather",
"tool-getWeather",
"text",
])
expect(message.parts[1]).toMatchObject({
type: "data-weather",
id: "weather-1",
data: {
status: "success",
},
})
expect(message.parts[2]).toMatchObject({
type: "tool-getWeather",
state: "output-available",
output: {
condition: "sunny",
},
})
})
it("produces identical transcripts for identical scripts", () => {
function script() {
return createChat<TestMessage>()
.user("Hello")
.assistant(({ writer }) => {
writer.reasoning("Checking the forecast.")
writer
.tool("getWeather", {
input: {
city: "San Francisco",
},
})
.output(weatherOutput)
writer.text("It's sunny.")
})
}
const first = script()
const second = script()
expect(first.get()).toEqual(second.get())
expect(first.get().map((message) => message.id)).toEqual(["msg-1", "msg-2"])
})
it("materializes user text parts without a streaming state", () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [userMessage, assistantMessage] = chat.get()
expect(userMessage.parts[0]).toEqual({
type: "text",
text: "Hello",
})
expect(assistantMessage.parts[0]).toMatchObject({
type: "text",
state: "done",
})
})
it("hydrates a chat from an existing transcript", () => {
const source = createChat<TestMessage>()
.user("What's the weather?")
.assistant(({ writer }) => {
writer
.tool("getWeather", {
input: { city: "San Francisco" },
})
.output(weatherOutput)
writer.text("It's sunny.", { mode: "instant" })
})
.user("Thanks!")
.assistant("Anytime.")
const recorded = source.get()
const replay = createChat({ messages: recorded })
expect(replay.get(2)).toEqual(recorded.slice(0, 2))
expect(replay.next(recorded.slice(0, 2))).toMatchObject({
id: recorded[2].id,
role: "user",
})
expect(replay.get()).toEqual(recorded)
})
it("continues generated ids past the seeded range", () => {
const source = createChat().user("Hello").assistant("Hey.")
const replay = createChat({ messages: source.get() }).user("What's next?")
const messages = replay.get()
expect(messages.map((message) => message.id)).toEqual([
"msg-1",
"msg-2",
"msg-3",
])
})
})

View File

@@ -0,0 +1,143 @@
import type {
ChatTransport,
UIDataTypes,
UIMessage,
UIMessagePart,
UITools,
} from "ai"
import { createChatRuntime, wait } from "../core"
import type { Chat, ChatOptions } from "../core"
import { createAiSdkFormat } from "./format"
// Local equivalents of the AI SDK's InferUIMessage* utilities; not all of
// them are exported from the pinned ai version.
type MessageMetadata<UI_MESSAGE extends UIMessage> =
UI_MESSAGE extends UIMessage<infer METADATA> ? METADATA : unknown
type MessageData<UI_MESSAGE extends UIMessage> =
UI_MESSAGE extends UIMessage<unknown, infer DATA_PARTS>
? DATA_PARTS
: UIDataTypes
type MessageTools<UI_MESSAGE extends UIMessage> =
UI_MESSAGE extends UIMessage<unknown, UIDataTypes, infer TOOLS>
? TOOLS
: UITools
/** Options for creating an AI SDK chat, optionally hydrated from existing messages. */
export type CreateChatOptions<UI_MESSAGE extends UIMessage = UIMessage> =
ChatOptions & {
messages?: Array<
UIMessage<
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>
>
}
export type YieldMessagePartsOptions = {
/** Also yield an initial snapshot with zero parts. */
includeEmpty?: boolean
}
export type StreamMessagePartsOptions = YieldMessagePartsOptions & {
/** Delay before each snapshot. */
delayMs?: number
}
/** The chat type returned by the AI SDK adapter's `createChat`. */
export type AiSdkChat<UI_MESSAGE extends UIMessage = UIMessage> = Chat<
UIMessage<
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>,
UIMessagePart<MessageData<UI_MESSAGE>, MessageTools<UI_MESSAGE>>,
ChatTransport<
UIMessage<
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>
>,
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>
/**
* Yields progressive copies of a message — one more part per snapshot — for
* rendering "message assembling itself" states without a transport.
*/
export function* yieldMessageParts<
METADATA = unknown,
DATA_PARTS extends UIDataTypes = UIDataTypes,
TOOLS extends UITools = UITools,
>(
message: UIMessage<METADATA, DATA_PARTS, TOOLS>,
options: YieldMessagePartsOptions = {}
) {
if (options.includeEmpty) {
yield {
...message,
parts: [],
}
}
for (let index = 0; index < message.parts.length; index++) {
yield {
...message,
parts: message.parts.slice(0, index + 1),
}
}
}
/** Async variant of {@link yieldMessageParts} that waits `delayMs` before each snapshot. */
export async function* streamMessageParts<
METADATA = unknown,
DATA_PARTS extends UIDataTypes = UIDataTypes,
TOOLS extends UITools = UITools,
>(
message: UIMessage<METADATA, DATA_PARTS, TOOLS>,
options: StreamMessagePartsOptions = {}
) {
const snapshots = yieldMessageParts(message, options)
while (true) {
const snapshot = snapshots.next()
if (snapshot.done) {
break
}
if (options.delayMs) {
await wait(options.delayMs)
}
yield snapshot.value
}
}
/**
* Creates an AI SDK chat, optionally hydrated from existing messages. Generic
* over the UI message type, mirroring `useChat`.
*/
export function createChat<UI_MESSAGE extends UIMessage = UIMessage>(
options: CreateChatOptions<UI_MESSAGE> = {}
) {
const { messages, ...chatOptions } = options
return createChatRuntime(
createAiSdkFormat<
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>(),
{
...chatOptions,
messages,
}
)
}

View File

@@ -0,0 +1,931 @@
import {
lastAssistantMessageIsCompleteWithApprovalResponses,
lastAssistantMessageIsCompleteWithToolCalls,
readUIMessageStream,
} from "ai"
import type { ChatTransport, UIMessage } from "ai"
import { describe, expect, it } from "vitest"
import { createChat } from "./index"
import { readStream, weatherLoading, weatherOutput } from "./test-utils"
import type { DataParts, TestMessage, Tools } from "./test-utils"
describe("AI SDK transport", () => {
it("chains tool output after an in-stream sleep", async () => {
const chat = createChat<TestMessage>()
.user("Weather?")
.assistant(({ writer }) => {
writer
.tool("getWeather", {
input: {
city: "San Francisco",
},
})
.sleep(100)
.output(weatherOutput)
})
const [userMessage] = chat.get(1)
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const reader = stream.getReader()
expect((await reader.read()).value?.type).toBe("start")
expect((await reader.read()).value?.type).toBe("tool-input-available")
const startedAt = Date.now()
const outputChunk = await reader.read()
expect(outputChunk.value?.type).toBe("tool-output-available")
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(75)
})
it("streams denied static tool parts", async () => {
const chat = createChat<TestMessage>()
.user("Use the available tools.")
.assistant([
{
type: "tool-getWeather",
toolCallId: "call-weather",
state: "output-denied",
input: { city: "San Francisco" },
approval: {
id: "approval-weather",
approved: false,
},
},
{
type: "dynamic-tool",
toolName: "searchDocs",
toolCallId: "call-docs",
state: "output-denied",
input: { query: "streaming" },
approval: {
id: "approval-docs",
approved: false,
},
},
])
const [userMessage] = chat.get(1)
const stream = await chat.transport({ delayMs: 0 }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"tool-input-available",
"tool-output-denied",
"tool-input-available",
"tool-output-denied",
"finish",
])
})
it("streams the next scripted assistant response through the transport", async () => {
const chat = createChat<TestMessage>()
.user("Hello")
.assistant("Hey, how's it going?")
.user("What's the weather?")
.assistant(({ writer }) => {
writer.reasoning("I need to call the weather tool.", {
mode: "instant",
})
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherLoading,
})
const weather = writer.tool("getWeather", {
input: {
city: "San Francisco",
},
})
weather.output(weatherOutput)
writer.text("It's sunny.", {
mode: "instant",
})
})
const initialMessages = chat.get(2)
const nextMessage = chat.next(initialMessages)
if (!nextMessage) {
throw new Error("Expected next message.")
}
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [
...initialMessages,
{
id: nextMessage.id ?? "next-message",
role: nextMessage.role ?? "user",
metadata: nextMessage.metadata,
parts: nextMessage.parts,
},
],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"reasoning-start",
"reasoning-delta",
"reasoning-end",
"data-weather",
"tool-input-available",
"tool-output-available",
"text-start",
"text-delta",
"text-end",
"finish",
])
expect(chunks).toContainEqual(
expect.objectContaining({
type: "tool-output-available",
output: {
city: "San Francisco",
temperature: 72,
condition: "sunny",
},
})
)
})
it("preserves streamed file filenames", async () => {
const chat = createChat()
.user("Send the report.")
.assistant(({ writer }) => {
writer.file({
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
})
})
const [userMessage] = chat.get(1)
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks).toContainEqual(
expect.objectContaining({
type: "file",
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
})
)
})
it("sleeps before the next scripted turn", async () => {
const chat = createChat()
.user("Hello")
.sleep(100)
.assistant(({ writer }) => {
writer.text("Hey.", {
mode: "instant",
})
})
const [userMessage] = chat.get(1)
const startedAt = Date.now()
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const reader = stream.getReader()
const firstChunk = await reader.read()
if (firstChunk.done) {
throw new Error("Expected first chunk.")
}
const chunks = [firstChunk.value]
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
}
expect(firstChunk.value?.type).toBe("start")
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(75)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"text-start",
"text-delta",
"text-end",
"finish",
])
})
it("emits a start chunk carrying the assistant message id", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [userMessage, assistantMessage] = chat.get()
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const reader = stream.getReader()
const firstChunk = await reader.read()
if (firstChunk.done) {
throw new Error("Expected first chunk.")
}
expect(firstChunk.value).toEqual({
type: "start",
messageId: assistantMessage.id,
})
const secondChunk = await reader.read()
if (secondChunk.done) {
throw new Error("Expected second chunk.")
}
expect(secondChunk.value?.type).toBe("text-start")
})
it("streams an app-level error before assistant content", async () => {
const chat = createChat().user("Hello").error("Model unavailable.")
const [userMessage] = chat.get(1)
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks).toEqual([
{
type: "start",
messageId: "msg-2",
},
{
type: "error",
errorText: "Model unavailable.",
},
])
})
it("streams an app-level error after partial assistant content", async () => {
const chat = createChat()
.user("Hello")
.assistant(({ writer }) => {
writer.text("Partial answer.", {
mode: "instant",
})
writer.error("Connection lost.")
})
const [userMessage] = chat.get(1)
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"text-start",
"text-delta",
"text-end",
"error",
])
expect(chunks.at(-1)).toEqual({
type: "error",
errorText: "Connection lost.",
})
})
it("emits an abort chunk when the request is aborted mid-stream", async () => {
const chat = createChat()
.user("Hello")
.assistant(({ writer }) => {
writer.text("First.", {
mode: "instant",
})
writer.sleep(150)
writer.text("Second.", {
mode: "instant",
})
})
const [userMessage] = chat.get(1)
const controller = new AbortController()
const stream = await chat.transport({ delayMs: 0 }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: controller.signal,
})
const reader = stream.getReader()
const received: string[] = []
while (received.at(-1) !== "text-end") {
const { done, value } = await reader.read()
if (done) {
throw new Error("Expected more chunks before the abort.")
}
received.push(value.type)
}
controller.abort()
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
}
expect(received).toEqual(["start", "text-start", "text-delta", "text-end"])
expect(chunks.map((chunk) => chunk.type)).toEqual(["abort"])
})
it("throws when no scripted assistant response remains", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const messages = chat.get()
await expect(
chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages,
abortSignal: undefined,
})
).rejects.toThrow("No assistant response found for this transcript.")
})
it("disables delta delays when delayMs is explicitly undefined", async () => {
const chat = createChat()
.user("Hello")
.assistant("This reply has several streamed words.")
const [userMessage] = chat.get(1)
const startedAt = Date.now()
const stream = await chat.transport({ delayMs: undefined }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(Date.now() - startedAt).toBeLessThan(100)
expect(
chunks.filter((chunk) => chunk.type === "text-delta").length
).toBeGreaterThan(1)
})
it("streams hydrated assistant turns through the transport", async () => {
const source = createChat<TestMessage>()
.user("What's the weather?")
.assistant(({ writer }) => {
writer
.tool("getWeather", {
input: { city: "San Francisco" },
})
.output(weatherOutput)
writer.text("It's sunny.", { mode: "instant" })
})
const replay = createChat<TestMessage>({
messages: source.get(),
})
const [userMessage] = replay.get(1)
const stream = await replay.transport({ delayMs: 0 }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"tool-input-available",
"tool-output-available",
"text-start",
"text-delta",
"text-end",
"finish",
])
})
it("streams a string fallback when the script is exhausted", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [unknownMessage] = createChat()
.user("Something unscripted", { id: "unknown-1" })
.get()
const stream = await chat
.transport({ delayMs: 0, fallback: "This demo is scripted." })
.sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [...chat.get(), unknownMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"text-start",
"text-delta",
"text-delta",
"text-delta",
"text-delta",
"text-end",
"finish",
])
expect(chunks).toContainEqual(
expect.objectContaining({
type: "text-delta",
delta: "scripted.",
})
)
})
it("passes the transcript to a fallback writer callback", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [unknownMessage] = createChat()
.user("Something unscripted", { id: "unknown-1" })
.get()
const stream = await chat
.transport({
delayMs: 0,
fallback: ({ writer, messages }) => {
writer.text(`You sent ${messages.length} messages.`, {
mode: "instant",
})
},
})
.sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [...chat.get(), unknownMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks).toContainEqual(
expect.objectContaining({
type: "text-delta",
delta: "You sent 3 messages.",
})
)
})
it("prefers scripted turns over the fallback", async () => {
const chat = createChat().user("Hello").assistant("Hey.", {
id: "scripted-reply",
})
const [userMessage] = chat.get(1)
const stream = await chat
.transport({ delayMs: 0, fallback: "Should not stream." })
.sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks).toContainEqual(
expect.objectContaining({
type: "text-delta",
delta: "Hey.",
})
)
})
it("still throws without a fallback", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
await expect(
chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: chat.get(),
abortSignal: undefined,
})
).rejects.toThrow("No assistant response found for this transcript.")
})
})
describe("AI SDK live client integration", () => {
// Sends the transcript the way the client does — `messageId` only on
// regeneration — then reduces the response through the real AI SDK
// message state machine, so streamed messages carry the ids a live
// `useChat` transcript would.
async function runLiveTurn(
transport: ChatTransport<UIMessage>,
messages: UIMessage[],
options: {
trigger?: "submit-message" | "regenerate-message"
messageId?: string
} = {}
) {
const stream = await transport.sendMessages({
trigger: options.trigger ?? "submit-message",
chatId: "chat-1",
messageId: options.messageId,
messages,
abortSignal: undefined,
})
let assistantMessage: UIMessage | undefined
for await (const message of readUIMessageStream({ stream })) {
assistantMessage = message
}
if (!assistantMessage) {
throw new Error("The stream produced no assistant message.")
}
return assistantMessage
}
function getText(message: UIMessage) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
}
it("streams assistant messages under their configured ids", async () => {
const chat = createChat()
.user("What changed?")
.assistant("Keyboard shortcuts and faster search.")
const [userMessage, assistantMessage] = chat.get()
const transport = chat.transport({ delayMs: 0 })
const streamed = await runLiveTurn(transport, [userMessage])
expect(streamed.id).toBe(assistantMessage.id)
expect(getText(streamed)).toBe("Keyboard shortcuts and faster search.")
})
it("falls back once a live transcript exhausts the conversation", async () => {
const chat = createChat().user("Hello").assistant("Hi.")
const transport = chat.transport({
delayMs: 0,
fallback: "No more scripted replies.",
})
const [userMessage] = chat.get(1)
const transcript: UIMessage[] = [userMessage]
transcript.push(await runLiveTurn(transport, transcript))
transcript.push({
id: "client-user-1",
role: "user",
parts: [{ type: "text", text: "Anything else?" }],
})
const fallbackMessage = await runLiveTurn(transport, transcript)
expect(getText(fallbackMessage)).toBe("No more scripted replies.")
})
it("regenerates the same turn from its adopted id", async () => {
const chat = createChat()
.user("Hello")
.assistant("Hi.")
.user("More?")
.assistant("Sure.")
const transport = chat.transport({ delayMs: 0 })
const [userMessage] = chat.get(1)
const streamed = await runLiveTurn(transport, [userMessage])
const regenerated = await runLiveTurn(transport, [userMessage], {
trigger: "regenerate-message",
messageId: streamed.id,
})
expect(regenerated.id).toBe(streamed.id)
expect(getText(regenerated)).toBe("Hi.")
})
it("regenerates a fallback response as another fallback", async () => {
const chat = createChat().user("Hello").assistant("Hi.")
const transport = chat.transport({
delayMs: 0,
fallback: "Still nothing scripted.",
})
const [userMessage] = chat.get(1)
const transcript: UIMessage[] = [userMessage]
transcript.push(await runLiveTurn(transport, transcript))
transcript.push({
id: "client-user-1",
role: "user",
parts: [{ type: "text", text: "More?" }],
})
const fallbackMessage = await runLiveTurn(transport, transcript)
const regenerated = await runLiveTurn(transport, transcript, {
trigger: "regenerate-message",
messageId: fallbackMessage.id,
})
expect(getText(regenerated)).toBe("Still nothing scripted.")
})
})
describe("AI SDK human-in-the-loop", () => {
function createApprovalChat() {
return createChat<TestMessage>()
.user("Fetch the weather?")
.assistant(({ writer }) => {
writer.text("I need your approval first.", { mode: "instant" })
writer.tool("getWeather", {
input: { city: "San Francisco" },
needsApproval: true,
output: weatherOutput,
})
})
.assistant(({ writer, toolCall }) => {
writer.text(toolCall?.approved ? "Fetched it." : "Okay, skipping.", {
mode: "instant",
})
})
}
async function readTurnChunks(
transport: ChatTransport<UIMessage<unknown, DataParts, Tools>>,
messages: Array<UIMessage<unknown, DataParts, Tools>>
) {
// Mirrors useChat's automatic sends, which pass the last message's id
// with the submit-message trigger. The transport must not treat that as
// a regeneration of the identified turn.
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: messages[messages.length - 1]?.id,
messages,
abortSignal: undefined,
})
return readStream(stream)
}
function respondToApproval(
message: UIMessage<unknown, DataParts, Tools>,
approved: boolean
) {
return {
...message,
parts: message.parts.map((part) =>
part.type === "tool-getWeather"
? ({
...part,
state: "approval-responded",
approval: { id: "approval-1", approved },
} as (typeof message.parts)[number])
: part
),
}
}
it("streams the approval request and the client parks the call", async () => {
const chat = createApprovalChat()
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
chat.get(1)
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"text-start",
"text-delta",
"text-end",
"tool-input-available",
"tool-approval-request",
"finish",
])
expect(chunks[5]).toMatchObject({
approvalId: "approval-1",
toolCallId: "call-1",
})
const stream = await chat.transport({ delayMs: undefined }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: chat.get(1),
abortSignal: undefined,
})
let clientMessage: UIMessage<unknown, DataParts, Tools> | undefined
for await (const message of readUIMessageStream({ stream })) {
clientMessage = message as UIMessage<unknown, DataParts, Tools>
}
expect(clientMessage?.parts).toMatchObject([
{ type: "text", text: "I need your approval first." },
{
type: "tool-getWeather",
state: "approval-requested",
approval: { id: "approval-1" },
},
])
})
it("streams the gated output and approved wording after approval", async () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
[userMessage, respondToApproval(assistantMessage, true)]
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"start-step",
"tool-output-available",
"text-start",
"text-delta",
"text-end",
"finish",
])
// Continuations omit the message id so the client updates the paused
// assistant message in place instead of forking a duplicate.
expect(chunks[0]).toMatchObject({ messageId: undefined })
expect(chunks[2]).toMatchObject({
toolCallId: "call-1",
output: weatherOutput,
})
expect(chunks[4]).toMatchObject({ delta: "Fetched it." })
})
it("streams the denial and denied wording after denial", async () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
[userMessage, respondToApproval(assistantMessage, false)]
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"start-step",
"tool-output-denied",
"text-start",
"text-delta",
"text-end",
"finish",
])
expect(chunks[2]).toMatchObject({ toolCallId: "call-1" })
expect(chunks[4]).toMatchObject({ delta: "Okay, skipping." })
})
it("does not retrigger automatic sending after a continuation merges", async () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const respondedMessage = respondToApproval(assistantMessage, true)
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
[userMessage, respondedMessage]
)
// The client merges these chunks into the prior assistant message as a
// new step. Rebuild that merged shape and assert the triggers ignore the
// already-resolved tool call behind the step boundary.
const mergedMessage = {
...respondedMessage,
parts: [
...respondedMessage.parts.map((part) =>
part.type === "tool-getWeather"
? ({
...part,
state: "output-available",
output: weatherOutput,
approval: { id: "approval-1", approved: true },
} as (typeof respondedMessage.parts)[number])
: part
),
{ type: "step-start" } as (typeof respondedMessage.parts)[number],
{
type: "text",
text: "Fetched it.",
} as (typeof respondedMessage.parts)[number],
],
}
expect(chunks.some((chunk) => chunk.type === "start-step")).toBe(true)
expect(
lastAssistantMessageIsCompleteWithToolCalls({
messages: [userMessage, mergedMessage],
})
).toBe(false)
expect(
lastAssistantMessageIsCompleteWithApprovalResponses({
messages: [userMessage, mergedMessage],
})
).toBe(false)
})
it("continues an elicitation with the user-submitted output", async () => {
const chat = createChat<TestMessage>()
.user("Help me plan the release.")
.assistant(({ writer }) => {
writer.tool("createFile", {
dynamic: true,
input: { filename: "plan.md", content: "" },
})
})
.assistant(({ writer, toolCall }) => {
writer.text(
toolCall?.name === "createFile" && toolCall.output
? `Saved ${toolCall.output.filename}.`
: "No file yet.",
{ mode: "instant" }
)
})
const [userMessage, assistantMessage] = chat.get(2)
const answeredMessage = {
...assistantMessage,
parts: assistantMessage.parts.map((part) =>
part.type === "dynamic-tool"
? ({
...part,
state: "output-available",
output: {
filename: "plan.md",
url: "https://example.com/plan.md",
},
} as (typeof assistantMessage.parts)[number])
: part
),
}
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
[userMessage, answeredMessage]
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"start-step",
"text-start",
"text-delta",
"text-end",
"finish",
])
expect(chunks[3]).toMatchObject({ delta: "Saved plan.md." })
})
})

View File

@@ -0,0 +1,276 @@
import type {
ChatTransport,
InferUIMessageChunk,
TextUIPart,
UIDataTypes,
UIMessage,
UIMessagePart,
UITools,
} from "ai"
import { assertNever } from "../core"
import type { ChatFormat, ChunkEncoder } from "../core"
import {
eventsFromParts,
getMessageText,
getToolCalls,
materializeParts,
} from "./parts"
/**
* The AI SDK `ChatFormat` plugin. Pass to `createChatRuntime` for advanced
* composition; `createChat` wires it up for you. Its transport `satisfies
* ChatTransport` and throws `"No assistant response found for this transcript."`
* when no assistant response remains.
*/
export function createAiSdkFormat<
METADATA = unknown,
DATA_PARTS extends UIDataTypes = UIDataTypes,
TOOLS extends UITools = UITools,
>(): ChatFormat<
UIMessage<METADATA, DATA_PARTS, TOOLS>,
UIMessagePart<DATA_PARTS, TOOLS>,
InferUIMessageChunk<UIMessage<METADATA, DATA_PARTS, TOOLS>>,
ChatTransport<UIMessage<METADATA, DATA_PARTS, TOOLS>>,
METADATA,
DATA_PARTS,
TOOLS
> {
type Message = UIMessage<METADATA, DATA_PARTS, TOOLS>
type Chunk = InferUIMessageChunk<Message>
const format: ChatFormat<
Message,
UIMessagePart<DATA_PARTS, TOOLS>,
Chunk,
ChatTransport<Message>,
METADATA,
DATA_PARTS,
TOOLS
> & {
encodeChunk: ChunkEncoder<Chunk, METADATA, DATA_PARTS, TOOLS>
} = {
materializeParts,
eventsFromParts,
createMessage(input) {
// User turns never stream, so their text parts carry no `state` —
// matching messages hand-built from `parts.text()`.
const parts =
input.role === "user"
? input.parts.map((part) => {
if (part.type !== "text") {
return part
}
const { state: _state, ...textPart } = part as TextUIPart
return textPart as UIMessagePart<DATA_PARTS, TOOLS>
})
: input.parts
return {
id: input.id,
role: input.role,
metadata: input.metadata,
parts,
} as Message
},
getMessageId(message) {
return message.id
},
getMessageRole(message) {
return message.role
},
getMessageText(message) {
return getMessageText(message)
},
getMessageParts(message) {
return message.parts
},
getMessageMetadata(message) {
return message.metadata
},
getToolCalls(message) {
return getToolCalls(message)
},
encodeChunk(chunk) {
switch (chunk.type) {
case "start":
// The client adopts this id for the streamed assistant message, so
// live transcripts keep the configured ids that turn matching,
// fallback, and regeneration rely on.
return {
type: "start",
messageId: chunk.messageId,
} as Chunk
case "start-step":
return {
type: "start-step",
} as Chunk
case "finish":
return {
type: "finish",
finishReason: chunk.finishReason,
messageMetadata: chunk.messageMetadata,
} as Chunk
case "abort":
return {
type: "abort",
reason: chunk.reason,
} as Chunk
case "error":
return {
type: "error",
errorText: chunk.errorText,
} as Chunk
case "text-start":
return {
type: "text-start",
id: chunk.id,
} as Chunk
case "text-delta":
return {
type: "text-delta",
id: chunk.id,
delta: chunk.delta,
} as Chunk
case "text-end":
return {
type: "text-end",
id: chunk.id,
} as Chunk
case "reasoning-start":
return {
type: "reasoning-start",
id: chunk.id,
} as Chunk
case "reasoning-delta":
return {
type: "reasoning-delta",
id: chunk.id,
delta: chunk.delta,
} as Chunk
case "reasoning-end":
return {
type: "reasoning-end",
id: chunk.id,
} as Chunk
case "data":
return {
type: `data-${chunk.name}`,
id: chunk.id,
data: chunk.data,
transient: chunk.transient,
} as Chunk
case "tool-input-available":
return {
type: "tool-input-available",
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
input: chunk.input,
providerExecuted: chunk.providerExecuted,
toolMetadata: chunk.toolMetadata,
dynamic: chunk.dynamic,
title: chunk.title,
} as Chunk
case "tool-approval-request":
return {
type: "tool-approval-request",
approvalId: chunk.approvalId,
toolCallId: chunk.toolCallId,
} as Chunk
case "tool-output-available":
return {
type: "tool-output-available",
toolCallId: chunk.toolCallId,
output: chunk.output,
providerExecuted: chunk.providerExecuted,
toolMetadata: chunk.toolMetadata,
dynamic: chunk.dynamic,
} as Chunk
case "tool-output-error":
return {
type: "tool-output-error",
toolCallId: chunk.toolCallId,
errorText: chunk.errorText,
providerExecuted: chunk.providerExecuted,
toolMetadata: chunk.toolMetadata,
dynamic: chunk.dynamic,
} as Chunk
case "tool-output-denied":
return {
type: "tool-output-denied",
toolCallId: chunk.toolCallId,
} as Chunk
case "file":
return {
type: "file",
filename: chunk.part.filename,
mediaType: chunk.part.mediaType,
url: chunk.part.url,
providerMetadata: chunk.part.providerMetadata,
} as Chunk
case "reasoning-file":
return {
...chunk.part,
type: "reasoning-file",
} as Chunk
case "source-url":
return {
...chunk.part,
type: "source-url",
} as Chunk
case "source-document":
return {
...chunk.part,
type: "source-document",
} as Chunk
case "custom":
return {
...chunk.part,
type: "custom",
} as Chunk
default:
return assertNever(chunk)
}
},
createTransport(transportContext, options = {}) {
return {
async sendMessages({ messages, messageId, abortSignal, trigger }) {
// Automatic sends after tool results and approvals pass the last
// assistant message's id; only regeneration may replay a turn by id.
const turn = transportContext.resolveTurn(
messages,
trigger === "regenerate-message" ? messageId : undefined
)
if (!turn) {
throw new Error("No assistant response found for this transcript.")
}
return transportContext.streamTurn(
turn,
format.encodeChunk,
options,
abortSignal
)
},
async reconnectToStream() {
return null
},
} satisfies ChatTransport<Message>
},
}
return format
}

View File

@@ -0,0 +1,2 @@
export { createChat } from "./chat"
export type { AiSdkChat, CreateChatOptions } from "./chat"

View File

@@ -0,0 +1,270 @@
import { describe, expect, it } from "vitest"
import { streamMessageParts, yieldMessageParts } from "./chat"
import { createChat } from "./index"
import { getToolCalls } from "./parts"
import {
artifact,
weatherLoading,
weatherOutput,
weatherSuccess,
} from "./test-utils"
import type { DataParts, TestMessage, Tools } from "./test-utils"
describe("AI SDK parts", () => {
it("creates typed messages with explicit parts", () => {
const [message] = createChat<TestMessage>()
.assistant(({ writer }) => {
writer.text("Here is the forecast.")
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherSuccess,
})
writer.tool("getWeather", {
toolCallId: "call-weather",
input: {
city: "San Francisco",
},
output: weatherOutput,
})
})
.get()
expect(message).toMatchObject({
id: "msg-1",
role: "assistant",
parts: [
{
type: "text",
text: "Here is the forecast.",
},
{
type: "data-weather",
id: "weather-1",
data: {
city: "San Francisco",
status: "success",
},
},
{
type: "tool-getWeather",
toolCallId: "call-weather",
state: "output-available",
},
],
})
})
it("creates rich messages with the fluent writer", () => {
const [message] = createChat<TestMessage>()
.assistant(
({ writer }) => {
writer.stepStart()
writer.reasoning("I should call a tool first.")
writer.tool("createFile", {
input: {
filename: "answer.md",
content: "Done.",
},
output: {
filename: "answer.md",
url: "https://example.com/answer.md",
},
})
writer.data({
type: "data-artifact",
data: artifact,
})
writer.text("The file is ready.")
},
{
id: "assistant-1",
}
)
.get()
expect(message.id).toBe("assistant-1")
expect(message.parts.map((part) => part.type)).toEqual([
"step-start",
"reasoning",
"tool-createFile",
"data-artifact",
"text",
])
})
it("yields message snapshots as parts become available", () => {
const [message] = createChat()
.assistant(
({ writer }) => {
writer.reasoning("Checking context.")
writer.text("Done.")
},
{
id: "assistant-1",
}
)
.get()
const snapshots = Array.from(
yieldMessageParts(message, {
includeEmpty: true,
})
)
expect(snapshots.map((snapshot) => snapshot.parts.length)).toEqual([
0, 1, 2,
])
expect(message.parts).toHaveLength(2)
})
it("streams message snapshots with the async generator", async () => {
const [message] = createChat()
.assistant(({ writer }) => {
writer.text("First.")
writer.text("Second.")
})
.get()
const snapshots = []
for await (const snapshot of streamMessageParts(message)) {
snapshots.push(snapshot)
}
expect(snapshots.map((snapshot) => snapshot.parts.length)).toEqual([1, 2])
})
it("materializes all writer part variants", () => {
const [message] = createChat<TestMessage>()
.assistant(
({ writer }) => {
writer.stepStart()
writer.reasoning("I will gather context before answering.")
writer.sourceUrl({
sourceId: "source-ai-sdk",
title: "AI SDK",
url: "https://ai-sdk.dev",
})
writer.file({
filename: "receipt.png",
mediaType: "image/png",
url: "https://example.com/receipt.png",
})
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherLoading,
})
writer.tool("getWeather", {
title: "Get weather",
input: { city: "San Francisco" },
output: weatherOutput,
})
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherSuccess,
})
writer.data({
type: "data-artifact",
data: artifact,
})
writer.text("The weather is sunny and the artifact is ready.")
},
{
id: "sample-all-parts",
}
)
.get()
expect(message.id).toBe("sample-all-parts")
expect(message.parts.map((part) => part.type)).toEqual([
"step-start",
"reasoning",
"source-url",
"file",
"data-weather",
"tool-getWeather",
"data-artifact",
"text",
])
expect(message.parts).toContainEqual(
expect.objectContaining({
type: "data-weather",
id: "weather-1",
data: weatherSuccess,
})
)
})
})
describe("AI SDK approval parts", () => {
function createApprovalMessage() {
const [, assistantMessage] = createChat<TestMessage>()
.user("Fetch the weather?")
.assistant(({ writer }) => {
writer.tool("getWeather", {
input: { city: "San Francisco" },
needsApproval: true,
output: weatherOutput,
})
})
.get(2)
return assistantMessage
}
it("materializes a needsApproval call as an approval-requested part", () => {
expect(createApprovalMessage().parts).toMatchObject([
{
type: "tool-getWeather",
toolCallId: "call-1",
state: "approval-requested",
input: { city: "San Francisco" },
approval: { id: "approval-1" },
},
])
})
it("summarizes tool calls with their approval state", () => {
const message = createApprovalMessage()
const respondedMessage = {
...message,
parts: message.parts.map((part) =>
part.type === "tool-getWeather"
? ({
...part,
state: "approval-responded",
approval: { id: "approval-1", approved: true },
} as (typeof message.parts)[number])
: part
),
}
expect(getToolCalls(respondedMessage)).toMatchObject([
{
toolCallId: "call-1",
name: "getWeather",
state: "approval-responded",
input: { city: "San Francisco" },
approval: { id: "approval-1", approved: true },
},
])
})
it("hydrates approval-requested parts back into approval events", () => {
const chat = createChat<TestMessage>({
messages: [createApprovalMessage()],
})
const [message] = chat.get(1)
expect(message.parts).toMatchObject([
{
type: "tool-getWeather",
state: "approval-requested",
approval: { id: "approval-1" },
},
])
})
})

View File

@@ -0,0 +1,478 @@
import type {
CustomContentUIPart,
DynamicToolUIPart,
FileUIPart,
ReasoningFileUIPart,
ReasoningUIPart,
SourceDocumentUIPart,
SourceUrlUIPart,
StepStartUIPart,
TextUIPart,
ToolUIPart,
UIDataTypes,
UIMessage,
UIMessagePart,
UITools,
} from "ai"
import { assertNever } from "../core"
import type { ChatEvent, ToolCallSummary } from "../core"
/** Concatenates the text of every `text` part in a message. */
export function getMessageText(message: Pick<UIMessage, "parts">) {
return message.parts
.filter((part): part is TextUIPart => part.type === "text")
.map((part) => part.text)
.join("")
}
function toToolCallSummary(
name: string,
part: ToolUIPart | DynamicToolUIPart,
dynamic?: boolean
): ToolCallSummary {
return {
toolCallId: part.toolCallId,
name,
dynamic,
input: "input" in part ? part.input : undefined,
output: part.state === "output-available" ? part.output : undefined,
errorText: part.state === "output-error" ? part.errorText : undefined,
state: part.state,
approval:
"approval" in part && part.approval
? {
id: part.approval.id,
approved: part.approval.approved,
reason: part.approval.reason,
}
: undefined,
}
}
/** Reads a message's typed and dynamic tool calls into framework-neutral summaries. */
export function getToolCalls(message: Pick<UIMessage, "parts">) {
const summaries: ToolCallSummary[] = []
for (const part of message.parts) {
if (part.type === "dynamic-tool") {
const dynamicToolPart = part as DynamicToolUIPart
summaries.push(
toToolCallSummary(dynamicToolPart.toolName, dynamicToolPart, true)
)
continue
}
if (part.type.startsWith("tool-") && "toolCallId" in part) {
const toolPart = part as ToolUIPart
summaries.push(
toToolCallSummary(part.type.replace("tool-", ""), toolPart)
)
}
}
return summaries
}
/** Replaces the part with the same `type` + `id` in place, or appends. */
export function replaceOrPushPart<
DATA_PARTS extends UIDataTypes,
TOOLS extends UITools,
>(
parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>,
part: UIMessagePart<DATA_PARTS, TOOLS>
) {
if ("id" in part && part.id) {
const index = parts.findIndex(
(currentPart) =>
currentPart.type === part.type &&
"id" in currentPart &&
currentPart.id === part.id
)
if (index !== -1) {
parts[index] = part
return
}
}
parts.push(part)
}
function findToolPartIndex<
DATA_PARTS extends UIDataTypes,
TOOLS extends UITools,
>(parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>, toolCallId: string) {
return parts.findIndex(
(part) =>
(part.type === "dynamic-tool" || part.type.startsWith("tool-")) &&
"toolCallId" in part &&
part.toolCallId === toolCallId
)
}
/** Materializes an event log into final AI SDK message parts (transient data excluded). */
export function materializeParts<
DATA_PARTS extends UIDataTypes,
TOOLS extends UITools,
>(
events: ChatEvent<DATA_PARTS, TOOLS>[]
): Array<UIMessagePart<DATA_PARTS, TOOLS>> {
const parts: Array<UIMessagePart<DATA_PARTS, TOOLS>> = []
for (const event of events) {
switch (event.kind) {
case "text": {
parts.push({
type: "text",
text: event.text,
state: "done",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "reasoning": {
parts.push({
type: "reasoning",
text: event.text,
state: "done",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "data": {
if (event.transient) {
continue
}
replaceOrPushPart(parts, {
type: `data-${event.name}`,
id: event.id,
data: event.data,
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "tool-input": {
replaceOrPushPart(parts, {
type: event.dynamic ? "dynamic-tool" : `tool-${event.name}`,
...(event.dynamic ? { toolName: event.name } : {}),
toolCallId: event.toolCallId,
title: event.title,
toolMetadata: event.toolMetadata,
providerExecuted: event.providerExecuted,
state: "input-available",
input: event.input,
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "tool-approval-request": {
const index = findToolPartIndex(parts, event.toolCallId)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "approval-requested",
approval: { id: event.approvalId },
} as UIMessagePart<DATA_PARTS, TOOLS>
}
break
}
case "tool-output": {
const index = findToolPartIndex(parts, event.toolCallId)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "output-available",
output: event.output,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
} as UIMessagePart<DATA_PARTS, TOOLS>
}
break
}
case "tool-error": {
const index = findToolPartIndex(parts, event.toolCallId)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "output-error",
errorText: event.errorText,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
} as UIMessagePart<DATA_PARTS, TOOLS>
}
break
}
case "tool-denied": {
const index = findToolPartIndex(parts, event.toolCallId)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "output-denied",
} as UIMessagePart<DATA_PARTS, TOOLS>
}
break
}
case "source-url": {
parts.push({
...event.part,
type: "source-url",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "source-document": {
parts.push({
...event.part,
type: "source-document",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "file": {
parts.push({
...event.part,
type: "file",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "reasoning-file": {
parts.push({
...event.part,
type: "reasoning-file",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "custom": {
parts.push({
...event.part,
type: "custom",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "step-start": {
parts.push({
type: "step-start",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "sleep":
case "error":
break
default:
assertNever(event)
}
}
return parts
}
/** Parses AI SDK message parts back into an event log so static parts can stream. */
export function eventsFromParts<
DATA_PARTS extends UIDataTypes,
TOOLS extends UITools,
>(
parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>
): ChatEvent<DATA_PARTS, TOOLS>[] {
const events: ChatEvent<DATA_PARTS, TOOLS>[] = []
for (const part of parts) {
// Framework-owned part unions are open, so parsing is non-exhaustive.
if (part.type === "text") {
const textPart = part as TextUIPart
events.push({
kind: "text",
id: `text-${events.length + 1}`,
text: textPart.text,
options: {
mode: "instant",
},
})
}
if (part.type === "reasoning") {
const reasoningPart = part as ReasoningUIPart
events.push({
kind: "reasoning",
id: `reasoning-${events.length + 1}`,
text: reasoningPart.text,
options: {
mode: "instant",
},
})
}
if (part.type.startsWith("data-")) {
events.push({
kind: "data",
name: part.type.replace("data-", "") as keyof DATA_PARTS & string,
id: "id" in part ? part.id : undefined,
data: "data" in part ? part.data : undefined,
} as ChatEvent<DATA_PARTS, TOOLS>)
}
if (part.type.startsWith("tool-") && "toolCallId" in part) {
const toolPart = part as ToolUIPart<TOOLS>
const name = part.type.replace("tool-", "") as keyof TOOLS & string
events.push({
kind: "tool-input",
name,
toolCallId: toolPart.toolCallId,
title: toolPart.title,
toolMetadata: toolPart.toolMetadata,
providerExecuted: toolPart.providerExecuted,
input: "input" in toolPart ? toolPart.input : {},
})
if (
toolPart.state === "approval-requested" ||
toolPart.state === "approval-responded"
) {
events.push({
kind: "tool-approval-request",
toolCallId: toolPart.toolCallId,
approvalId: toolPart.approval.id,
})
}
if (toolPart.state === "output-available") {
events.push({
kind: "tool-output",
toolCallId: toolPart.toolCallId,
output: toolPart.output,
providerExecuted: toolPart.providerExecuted,
toolMetadata: toolPart.toolMetadata,
})
}
if (toolPart.state === "output-error") {
events.push({
kind: "tool-error",
toolCallId: toolPart.toolCallId,
errorText: toolPart.errorText,
providerExecuted: toolPart.providerExecuted,
toolMetadata: toolPart.toolMetadata,
})
}
if (toolPart.state === "output-denied") {
events.push({
kind: "tool-denied",
toolCallId: toolPart.toolCallId,
})
}
}
if (part.type === "dynamic-tool") {
const dynamicToolPart = part as DynamicToolUIPart
events.push({
kind: "tool-input",
name: dynamicToolPart.toolName as keyof TOOLS & string,
toolCallId: dynamicToolPart.toolCallId,
title: dynamicToolPart.title,
toolMetadata: dynamicToolPart.toolMetadata,
providerExecuted: dynamicToolPart.providerExecuted,
dynamic: true,
input: "input" in dynamicToolPart ? dynamicToolPart.input : {},
})
if (
dynamicToolPart.state === "approval-requested" ||
dynamicToolPart.state === "approval-responded"
) {
events.push({
kind: "tool-approval-request",
toolCallId: dynamicToolPart.toolCallId,
approvalId: dynamicToolPart.approval.id,
})
}
if (dynamicToolPart.state === "output-available") {
events.push({
kind: "tool-output",
toolCallId: dynamicToolPart.toolCallId,
output: dynamicToolPart.output,
providerExecuted: dynamicToolPart.providerExecuted,
toolMetadata: dynamicToolPart.toolMetadata,
dynamic: true,
})
}
if (dynamicToolPart.state === "output-error") {
events.push({
kind: "tool-error",
toolCallId: dynamicToolPart.toolCallId,
errorText: dynamicToolPart.errorText,
providerExecuted: dynamicToolPart.providerExecuted,
toolMetadata: dynamicToolPart.toolMetadata,
dynamic: true,
})
}
if (dynamicToolPart.state === "output-denied") {
events.push({
kind: "tool-denied",
toolCallId: dynamicToolPart.toolCallId,
})
}
}
if (part.type === "source-url") {
const sourceUrlPart = part as SourceUrlUIPart
events.push({
kind: "source-url",
part: sourceUrlPart,
})
}
if (part.type === "source-document") {
const sourceDocumentPart = part as SourceDocumentUIPart
events.push({
kind: "source-document",
part: sourceDocumentPart,
})
}
if (part.type === "file") {
const filePart = part as FileUIPart
events.push({
kind: "file",
part: filePart,
})
}
if (part.type === "reasoning-file") {
const reasoningFilePart = part as ReasoningFileUIPart
events.push({
kind: "reasoning-file",
part: reasoningFilePart,
})
}
if (part.type === "custom") {
const customPart = part as CustomContentUIPart
events.push({
kind: "custom",
part: customPart,
})
}
if (part.type === "step-start") {
events.push({
kind: "step-start",
})
}
}
return events
}

View File

@@ -0,0 +1,83 @@
import type { UIMessage } from "ai"
export type TestMessage = UIMessage<unknown, DataParts, Tools>
export type DataParts = {
weather: {
city: string
status: "loading" | "success" | "error"
temperature?: number
condition?: string
}
artifact: {
id: string
title: string
kind: "code" | "document" | "image"
status: "draft" | "streaming" | "complete"
}
}
export type Tools = {
getWeather: {
input: {
city: string
}
output: {
city: string
temperature: number
condition: string
}
}
createFile: {
input: {
filename: string
content: string
}
output: {
filename: string
url: string
}
}
}
export const weatherLoading: DataParts["weather"] = {
city: "San Francisco",
status: "loading",
}
export const weatherSuccess: DataParts["weather"] = {
city: "San Francisco",
status: "success",
temperature: 72,
condition: "sunny",
}
export const weatherOutput: Tools["getWeather"]["output"] = {
city: "San Francisco",
temperature: 72,
condition: "sunny",
}
export const artifact: DataParts["artifact"] = {
id: "artifact-1",
title: "CitedAnswer.jsx",
kind: "code",
status: "complete",
}
export async function readStream<T>(stream: ReadableStream<T>) {
const chunks = []
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
}
return chunks
}

View File

@@ -0,0 +1,621 @@
import { describe, expect, it, vi } from "vitest"
import { createChatRuntime } from "./chat"
import { createTestChat, createTestFormat, readStream } from "./test-utils"
import type {
TestData,
TestMessage,
TestPart,
TestTools,
TestTransport,
} from "./test-utils"
import type { NeutralChunk } from "./types"
describe("createChatRuntime", () => {
it("creates user and assistant turns with sequential ids and metadata", () => {
const chat = createTestChat().user("Hello").assistant("Hey.")
const messages = chat.get()
expect(messages).toMatchObject([
{
id: "msg-1",
role: "user",
metadata: { createdAt: "2026-01-01T00:00:00.000Z" },
parts: [{ type: "text", text: "Hello" }],
},
{
id: "msg-2",
role: "assistant",
parts: [{ type: "text", text: "Hey." }],
},
])
})
it("attaches user files as parts", () => {
const chat = createTestChat().user("See attached.", {
files: [
{
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
},
],
})
expect(chat.get()[0].parts).toMatchObject([
{ type: "text", text: "See attached." },
{ type: "file", filename: "report.pdf" },
])
})
it("stores explicit assistant parts as-is", () => {
const parts = [{ type: "text", text: "Prebuilt." }]
const chat = createTestChat().assistant(parts)
expect(chat.get()[0].parts).toMatchObject(parts)
})
it("returns cloned messages from get", () => {
const chat = createTestChat().user("Hello")
const [first] = chat.get()
first.parts[0].text = "Mutated"
expect(chat.get()[0].parts[0].text).toBe("Hello")
})
it("owns explicit parts and deeply detached snapshots", () => {
const parts = [
{
type: "tool-getWeather",
input: { location: { city: "SF" } },
},
]
const chat = createTestChat().assistant(parts)
;(parts[0].input.location as { city: string }).city = "mutated input"
const [snapshot] = chat.get()
const snapshotPart = snapshot.parts[0] as unknown as {
input: { location: { city: string } }
}
snapshotPart.input.location.city = "mutated snapshot"
expect(chat.get()[0].parts[0]).toMatchObject({
input: { location: { city: "SF" } },
})
})
it("returns a transcript prefix without mutating playback", () => {
const chat = createTestChat()
.user("One")
.assistant("A")
.user("Two")
.assistant("B")
.user("Three")
const initialMessages = chat.get(2)
const first = chat.next(initialMessages)
const second = chat.next(initialMessages)
expect(first?.parts[0].text).toBe("Two")
expect(second?.parts[0].text).toBe("Two")
})
it("finds the next user turn from a transcript by id", () => {
const chat = createTestChat().user("One").assistant("A").user("Two")
const [firstMessage] = chat.get(1)
const nextMessage = chat.next([firstMessage])
expect(nextMessage?.parts[0].text).toBe("Two")
})
it("falls back to role and text matching for unscripted transcripts", () => {
const chat = createTestChat().user("One").assistant("A").user("Two")
const next = chat.next([
{
id: "external-1",
role: "user",
parts: [{ type: "text", text: "One" }],
},
])
expect(next?.parts[0].text).toBe("Two")
})
it("returns the first user message for an empty transcript and null when exhausted", () => {
const chat = createTestChat().user("One").assistant("A")
expect(chat.next([])?.parts[0].text).toBe("One")
expect(chat.next(chat.get())).toBeNull()
})
it("rejects invalid get counts", () => {
const chat = createTestChat().user("One")
expect(() => chat.get(-1)).toThrowError(
"count must be a non-negative integer."
)
expect(() => chat.get(1.5)).toThrowError(
"count must be a non-negative integer."
)
})
it("requires counts for get and message transcripts for next", () => {
const chat = createTestChat()
if (false) {
// @ts-expect-error get accepts a count, not an options object.
chat.get({ count: 1 })
// @ts-expect-error next requires the current message transcript.
chat.next()
// @ts-expect-error next accepts messages, not transcript indexes.
chat.next([1])
}
expect(chat.get()).toEqual([])
})
it("streams the next scripted assistant turn through the transport", async () => {
const chat = createTestChat()
.user("Weather?")
.assistant(({ writer }) => {
writer.reasoning("Checking.", { mode: "instant" })
writer
.tool("getWeather", { input: { city: "SF" } })
.output({ temperature: 72 })
writer.text("Sunny.", { mode: "instant" })
})
const [userMessage] = chat.get(1)
const { context, options } = chat.transport({ delayMs: 0 })
const turn = context.resolveTurn([userMessage])
expect(options).toEqual({ delayMs: 0 })
expect(turn?.role).toBe("assistant")
const chunks = await readStream(
context.streamTurn(turn!, (chunk) => chunk, options)
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"reasoning-start",
"reasoning-delta",
"reasoning-end",
"tool-input-available",
"tool-output-available",
"text-start",
"text-delta",
"text-end",
"finish",
])
})
it("streams scripted errors and stops", async () => {
const chat = createTestChat().user("Hello").error("Model unavailable.")
const [userMessage] = chat.get(1)
const { context } = chat.transport()
const turn = context.resolveTurn([userMessage])
const chunks = await readStream(
context.streamTurn(turn!, (chunk) => chunk, { delayMs: 0 })
)
expect(chunks).toMatchObject([
{ type: "start" },
{ type: "error", errorText: "Model unavailable." },
])
})
it("resolves turns by message id", () => {
const chat = createTestChat()
.user("One")
.assistant("A", { id: "assistant-a" })
.user("Two")
.assistant("B", { id: "assistant-b" })
const { context } = chat.transport()
expect(context.resolveTurn([], "assistant-b")?.message.id).toBe(
"assistant-b"
)
const turnAfterOne = context.resolveTurn(chat.get(1))
expect(turnAfterOne?.message.id).toBe("assistant-a")
})
it("carries assistant metadata on the finish chunk", async () => {
const chat = createTestChat()
.user("Hello")
.assistant("Hey.", { metadata: { model: "gpt-5" } })
const [userMessage] = chat.get(1)
const { context } = chat.transport()
const turn = context.resolveTurn([userMessage])
const chunks = await readStream(
context.streamTurn(turn!, (chunk) => chunk, { delayMs: 0 })
)
const finish = chunks.find((chunk) => chunk.type === "finish")
expect(finish).toMatchObject({
messageMetadata: { model: "gpt-5" },
})
})
it("sleeps before the next scripted turn", async () => {
const chat = createTestChat()
.user("Hello")
.sleep(60)
.assistant(({ writer }) => {
writer.text("Hey.", { mode: "instant" })
})
const [userMessage] = chat.get(1)
const { context } = chat.transport()
const turn = context.resolveTurn([userMessage])
const startedAt = Date.now()
const chunks = await readStream(
context.streamTurn(turn!, (chunk) => chunk, { delayMs: 0 })
)
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(40)
expect(chunks[0]).toMatchObject({ type: "start" })
})
})
describe("createChatRuntime hydration and fallback", () => {
it("seeds turns from existing messages and reserves message ids", () => {
const format = createTestFormat<TestData, TestTools>()
const source = createTestChat().user("Hello").assistant("Hey.")
const recorded = source.get()
const replay = createChatRuntime<
TestMessage,
TestPart,
NeutralChunk<unknown, TestData, TestTools>,
TestTransport<TestData, TestTools>,
unknown,
TestData,
TestTools
>(format, { messages: recorded })
expect(replay.get()).toEqual(recorded)
replay.user("What's next?")
const messages = replay.get()
expect(messages[2].id).toBe("msg-3")
expect(messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"user",
])
})
it("continues past the highest seeded id without relying on transcript length", () => {
const format = createTestFormat<TestData, TestTools>()
const replay = createChatRuntime<
TestMessage,
TestPart,
NeutralChunk<unknown, TestData, TestTools>,
TestTransport<TestData, TestTools>,
unknown,
TestData,
TestTools
>(format, {
messages: [
{
id: "msg-2",
role: "assistant",
parts: [{ type: "text", text: "Seeded" }],
},
],
})
replay.user("Next")
expect(replay.get().map((message) => message.id)).toEqual([
"msg-2",
"msg-3",
])
})
it("resolves a fallback turn only when configured", () => {
const chat = createTestChat().user("Hello").assistant("Hey.")
// A transcript containing the full script is exhausted — no scripted
// assistant turn remains, which is when the fallback kicks in.
const exhausted: TestMessage[] = [
...chat.get(),
{
id: "unknown-1",
role: "user",
parts: [{ type: "text", text: "Unscripted" }],
},
]
const strict = chat.transport()
expect(strict.context.resolveTurn(exhausted)).toBeUndefined()
const lenient = chat.transport({ fallback: "Scripted demo." })
const turn = lenient.context.resolveTurn(exhausted)
expect(turn).toMatchObject({
role: "assistant",
})
expect(turn?.events).toContainEqual(
expect.objectContaining({
kind: "text",
text: "Scripted demo.",
})
)
})
it("does not add fallback turns to the script", () => {
const chat = createTestChat().user("Hello").assistant("Hey.")
const transport = chat.transport({ fallback: "Scripted demo." })
transport.context.resolveTurn([
...chat.get(),
{
id: "unknown-1",
role: "user",
parts: [{ type: "text", text: "Unscripted" }],
},
])
expect(chat.get()).toHaveLength(2)
})
})
describe("human-in-the-loop turns", () => {
function createApprovalChat() {
return createTestChat()
.user("Fetch the weather?")
.assistant(({ writer }) => {
writer.tool("getWeather", {
input: { city: "Paris" },
needsApproval: true,
output: { temperature: 21 },
})
})
.assistant(({ writer, toolCall }) => {
writer.text(toolCall?.approved ? "Fetched it." : "Okay, skipping.")
})
}
function withApprovalResponse(message: TestMessage, approved: boolean) {
return {
...message,
parts: message.parts.map((part) =>
part.type === "tool-getWeather"
? {
...part,
state: "approval-responded",
approval: { id: "approval-1", approved },
}
: part
),
}
}
it("scripts the approval request and pauses the turn", () => {
const chat = createApprovalChat()
const turn = chat.transport().context.resolveTurn(chat.get(1))
expect(turn?.events).toMatchObject([
{ kind: "tool-input", toolCallId: "call-1" },
{
kind: "tool-approval-request",
toolCallId: "call-1",
approvalId: "approval-1",
output: { temperature: 21 },
},
])
expect(turn?.message.parts).toMatchObject([
{
type: "tool-getWeather",
state: "approval-requested",
approval: { id: "approval-1" },
},
])
})
it("bounds get() at the first continuation turn", () => {
const chat = createApprovalChat()
expect(chat.get()).toHaveLength(2)
expect(() => chat.get(3)).toThrow(/continuation/)
})
it("streams the gated output and approved context after approval", () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const turn = chat
.transport()
.context.resolveTurn([
userMessage,
withApprovalResponse(assistantMessage, true),
])
expect(turn?.message.id).toBe("msg-3")
expect(turn?.events).toMatchObject([
{ kind: "step-start" },
{
kind: "tool-output",
toolCallId: "call-1",
output: { temperature: 21 },
},
{ kind: "text", text: "Fetched it." },
])
})
it("streams the denial and denied context after denial", () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const turn = chat
.transport()
.context.resolveTurn([
userMessage,
withApprovalResponse(assistantMessage, false),
])
expect(turn?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "tool-denied", toolCallId: "call-1" },
{ kind: "text", text: "Okay, skipping." },
])
})
it("re-resolves a continuation to the same message", () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const transcript = [
userMessage,
withApprovalResponse(assistantMessage, true),
]
const { context } = chat.transport()
const first = context.resolveTurn(transcript)
const second = context.resolveTurn(transcript)
expect(second?.message).toEqual(first?.message)
})
it("reads a user-submitted elicitation output into the continuation", () => {
const chat = createTestChat()
.user("Plan the release?")
.assistant(({ writer }) => {
writer.tool("getWeather", { input: { city: "Paris" } })
})
.assistant(({ writer, toolCall }) => {
writer.text(
toolCall?.name === "getWeather" && toolCall.output
? `Temperature ${toolCall.output.temperature}.`
: "No answer."
)
})
const [userMessage, assistantMessage] = chat.get(2)
const answeredMessage = {
...assistantMessage,
parts: assistantMessage.parts.map((part) =>
part.type === "tool-getWeather"
? { ...part, state: "output-available", output: { temperature: 72 } }
: part
),
}
const turn = chat
.transport()
.context.resolveTurn([userMessage, answeredMessage])
expect(turn?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "text", text: "Temperature 72." },
])
})
it("chains continuations and warns when nothing is pending", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
const chat = createTestChat()
.user("Plan the release?")
.assistant(({ writer }) => {
writer.tool("getWeather", { input: { city: "Paris" } })
})
.assistant(({ writer }) => {
writer.text("Thanks.")
})
.assistant(({ writer, toolCalls }) => {
writer.text(`Pending ${toolCalls?.length ?? 0}.`)
})
const [userMessage, assistantMessage] = chat.get(2)
const answeredMessage = {
...assistantMessage,
parts: assistantMessage.parts.map((part) =>
part.type === "tool-getWeather"
? { ...part, state: "output-available", output: { temperature: 72 } }
: part
),
}
const { context } = chat.transport()
const firstContinuation = context.resolveTurn([
userMessage,
answeredMessage,
])
const secondContinuation = context.resolveTurn([
userMessage,
answeredMessage,
firstContinuation!.message,
])
expect(firstContinuation?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "text", text: "Thanks." },
])
expect(secondContinuation?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "text", text: "Pending 0." },
])
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("without a pending tool call")
)
warn.mockRestore()
})
it("starts eager continuation turns with a step boundary", () => {
const chat = createTestChat()
.user("Plan the release?")
.assistant(({ writer }) => {
writer.tool("getWeather", { input: { city: "Paris" } })
})
.assistant("Thanks.")
const [userMessage, assistantMessage] = chat.get(2)
const turn = chat
.transport()
.context.resolveTurn([userMessage, assistantMessage])
expect(turn?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "text", text: "Thanks." },
])
})
it("warns when an approval request has no continuation turn", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
createTestChat()
.user("Fetch the weather?")
.assistant(({ writer }) => {
writer.tool("getWeather", {
input: { city: "Paris" },
needsApproval: true,
output: { temperature: 21 },
})
})
.transport()
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("no continuation turn follows")
)
warn.mockRestore()
})
it("throws when a needsApproval handle is resolved by the script", () => {
createTestChat()
.user("Fetch the weather?")
.assistant(({ writer }) => {
const handle = writer.tool("getWeather", {
input: { city: "Paris" },
needsApproval: true,
})
expect(() => handle.output({ temperature: 21 })).toThrow(
/needsApproval/
)
})
})
})

View File

@@ -0,0 +1,814 @@
import { createChatIds } from "./ids"
import { lowerEvents } from "./lower"
import { createChatPayloads, createMetadataFactory } from "./payloads"
import { createTurnStream } from "./stream"
import type {
ChatAssistantOptions,
ChatEvent,
ChatFormat,
ChatOptions,
ChatTurn,
ChatUserOptions,
DataTypes,
MessageRole,
PendingToolCall,
ToolSet,
TurnStreamOptions,
} from "./types"
import {
cloneValue,
DEFAULT_STREAM_DELAY_MS,
DEFAULT_TEXT,
devWarn,
} from "./utils"
import { createEventWriter } from "./writer"
import type { EventWriter } from "./writer"
/**
* A transport response for when no configured assistant response remains —
* most commonly an exhausted chat (the user keeps sending past the end).
* A string (one streamed text part), a parts array, or a writer callback
* that also receives the incoming transcript. Note: fully unrecognized
* transcripts replay the chat from the top rather than falling back.
*/
export type ChatFallback<
MESSAGE,
PART,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
WRITER = EventWriter<DATA, TOOLS>,
> =
| string
| PART[]
| ((context: { writer: WRITER; messages: MESSAGE[] }) => void)
/**
* Transport options: `delayMs` between text and reasoning deltas (default 50,
* explicit `undefined` disables delays) and an optional `fallback` used when
* no configured assistant response remains instead of throwing.
*/
export type ChatTransportOptions<
MESSAGE,
PART,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
WRITER = EventWriter<DATA, TOOLS>,
> = TurnStreamOptions & {
fallback?: ChatFallback<MESSAGE, PART, DATA, TOOLS, WRITER>
}
/**
* The context handed to assistant writer callbacks. A continuation turn — a
* callback turn scripted after a turn that pauses for user input — also
* receives the live transcript and the resolved human-in-the-loop tool
* calls; eager turns receive only the writer.
*/
export type AssistantTurnContext<
WRITER,
MESSAGE,
TOOLS extends ToolSet = ToolSet,
> = {
writer: WRITER
messages?: MESSAGE[]
toolCall?: PendingToolCall<TOOLS>
toolCalls?: Array<PendingToolCall<TOOLS>>
}
/** A deterministic conversation: chain turns, then read them back or stream them through a transport. */
export type Chat<
MESSAGE,
PART,
TRANSPORT,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
WRITER = EventWriter<DATA, TOOLS>,
> = {
/** Scripts a user turn: one text part plus optional file attachments. */
user(
text?: string,
options?: ChatUserOptions<METADATA>
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
/**
* Scripts an assistant turn from a string (one streamed text part), a
* parts array (static parts, streamed instantly), or a writer callback
* (full control over parts and timing). A callback turn scripted after a
* paused turn — an unresolved tool call or `needsApproval` — becomes a
* continuation: it materializes when the follow-up request arrives, with
* the live transcript and resolved tool calls in its context.
*/
assistant(
input?:
| string
| PART[]
| ((context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>) => void),
options?: ChatAssistantOptions<METADATA>
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
/** Scripts an assistant turn that streams a bare error chunk and ends. */
error(
errorText?: string
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
/** Delays the next turn's stream. */
sleep(
delayMs: number
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
/**
* Returns clones of the first `count` configured messages, or all
* materializable messages when omitted. Continuation turns have no message
* without a live transcript, so `get` stops before the first one and
* throws when `count` reaches past it.
*/
get(count?: number): MESSAGE[]
/**
* Returns the next configured user message after the given transcript, or
* `null` when none remain. Matches by message id, falling back to role and
* text, without mutating the chat.
*/
next(messages: readonly MESSAGE[]): MESSAGE | null
/** Creates the framework transport that streams configured assistant responses. */
transport(
options?: ChatTransportOptions<MESSAGE, PART, DATA, TOOLS, WRITER>
): TRANSPORT
}
/**
* Creates a {@link Chat} bound to a {@link ChatFormat}. This is the
* factory every adapter's `createChat` wraps — pass your own format to
* target a framework this package doesn't ship an adapter for.
*/
export function createChatRuntime<
MESSAGE,
PART,
CHUNK,
TRANSPORT,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
WRITER = EventWriter<DATA, TOOLS>,
>(
format: ChatFormat<MESSAGE, PART, CHUNK, TRANSPORT, METADATA, DATA, TOOLS>,
options: ChatOptions & {
/** Hydrate the chat from existing messages, e.g. a recorded transcript. */
messages?: MESSAGE[]
} = {}
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER> {
type InternalTurn = {
role: MessageRole
events: ChatEvent<DATA, TOOLS>[]
messageId: string
metadata?: METADATA
// Continuation turns carry `resolve` instead of a materialized message.
message?: MESSAGE
resolve?: (context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>) => void
// Continuations stream without a message id so the client keeps merging
// into the assistant message it is already continuing.
continuation?: boolean
// The events last streamed for a continuation turn. Later continuations
// read pending calls from here so re-runs cannot drift generated ids
// away from the transcript.
lastEvents?: ChatEvent<DATA, TOOLS>[]
}
type ResolvedTurn = ChatTurn<MESSAGE, DATA, TOOLS> & {
metadata?: METADATA
continuation?: boolean
}
const ids = createChatIds(options)
const payloads = createChatPayloads(ids)
const metadata = createMetadataFactory<METADATA>(options.now)
const turns: InternalTurn[] = []
const pendingEvents: ChatEvent<DATA, TOOLS>[] = []
function reserveEventIds(events: ChatEvent<DATA, TOOLS>[]) {
for (const event of events) {
if (
event.kind === "tool-input" ||
event.kind === "tool-output" ||
event.kind === "tool-error" ||
event.kind === "tool-denied" ||
event.kind === "tool-approval-request"
) {
ids.reserveToolCallId(event.toolCallId)
}
if (event.kind === "tool-approval-request") {
ids.reserveApprovalId(event.approvalId)
}
if (event.kind === "source-url" || event.kind === "source-document") {
ids.reserveSourceId(event.part.sourceId)
}
}
}
function getPendingToolInputs(events: ChatEvent<DATA, TOOLS>[]) {
const resolvedToolCallIds = new Set<string>()
for (const event of events) {
if (
event.kind === "tool-output" ||
event.kind === "tool-error" ||
event.kind === "tool-denied"
) {
resolvedToolCallIds.add(event.toolCallId)
}
}
return events.flatMap((event) =>
event.kind === "tool-input" && !resolvedToolCallIds.has(event.toolCallId)
? [
{
input: event,
approval: events.find(
(
candidate
): candidate is Extract<
ChatEvent<DATA, TOOLS>,
{ kind: "tool-approval-request" }
> =>
candidate.kind === "tool-approval-request" &&
candidate.toolCallId === event.toolCallId
),
},
]
: []
)
}
function resolveMessageId(id?: string) {
if (id !== undefined) {
ids.reserveMessageId(id)
return id
}
return ids.nextMessageId()
}
for (const seededMessage of options.messages ?? []) {
const message = cloneValue(seededMessage)
const events = format.eventsFromParts(format.getMessageParts(message))
ids.reserveMessageId(format.getMessageId(message))
reserveEventIds(events)
turns.push({
role: format.getMessageRole(message),
message,
messageId: format.getMessageId(message),
events,
metadata: format.getMessageMetadata?.(message),
})
}
function takePendingEvents() {
return pendingEvents.splice(0, pendingEvents.length)
}
function pushTurn(turn: InternalTurn) {
turns.push(turn)
return api
}
function findLatestChatIndex(messages: readonly MESSAGE[]) {
const messageIds = new Set(
messages.map((message) => format.getMessageId(message))
)
let latestChatIndex = -1
for (let index = 0; index < turns.length; index++) {
if (messageIds.has(turns[index].messageId)) {
latestChatIndex = index
}
}
if (latestChatIndex === -1) {
const lastMessage = messages[messages.length - 1]
latestChatIndex = turns.findIndex(
(turn) =>
lastMessage !== undefined &&
turn.message !== undefined &&
turn.role === format.getMessageRole(lastMessage) &&
format.getMessageText(turn.message) ===
format.getMessageText(lastMessage)
)
}
return latestChatIndex
}
function findAssistantTurnIndexAfter(startIndex: number) {
for (let index = startIndex; index < turns.length; index++) {
if (turns[index].role === "assistant") {
return index
}
}
return -1
}
function findNextAssistantTurnIndex(messages: MESSAGE[], messageId?: string) {
if (messageId) {
const index = turns.findIndex((turn) => turn.messageId === messageId)
if (turns[index]?.role === "assistant") {
return index
}
// Unknown ids fall through to transcript matching so regenerating a
// fallback-produced message falls back again instead of replaying the
// first configured response.
if (index !== -1) {
return findAssistantTurnIndexAfter(index + 1)
}
}
return findAssistantTurnIndexAfter(findLatestChatIndex(messages) + 1)
}
function findNextUserTurn(messages: readonly MESSAGE[]) {
const latestChatIndex = findLatestChatIndex(messages)
return turns.slice(latestChatIndex + 1).find((turn) => turn.role === "user")
}
function materializeAssistantInput(
input:
| string
| PART[]
| ((context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>) => void),
events: ChatEvent<DATA, TOOLS>[]
): PART[] {
if (typeof input === "string") {
const writer = createEventWriter(events, { ids, payloads })
writer.text(input)
return format.materializeParts(events)
}
if (typeof input === "function") {
// `WRITER` is an adapter-narrowed subset of the full writer created
// here. This is the single cast point that passes the full writer
// through the adapter's narrower callback type.
const writer = createEventWriter(events, {
ids,
payloads,
}) as unknown as WRITER
input({ writer })
reserveEventIds(events)
return format.materializeParts(events)
}
const parts = cloneValue(input)
const partEvents = format.eventsFromParts(parts)
reserveEventIds(partEvents)
events.push(...partEvents)
return parts
}
function createFallbackTurn(
fallback: ChatFallback<MESSAGE, PART, DATA, TOOLS, WRITER>,
messages: MESSAGE[]
): ResolvedTurn {
const events: ChatEvent<DATA, TOOLS>[] = []
const input =
typeof fallback === "function"
? (context: { writer: WRITER }) => fallback({ ...context, messages })
: fallback
const parts = materializeAssistantInput(input, events)
const turnMetadata = metadata()
return {
role: "assistant",
message: format.createMessage({
id: resolveMessageId(),
role: "assistant",
metadata: turnMetadata,
parts,
}),
events,
metadata: turnMetadata,
}
}
function findPreviousAssistantIndex(turnIndex: number) {
for (let index = turnIndex - 1; index >= 0; index--) {
if (turns[index].role === "assistant") {
return index
}
}
return -1
}
function isPreviousTurnPausedAssistant() {
const previousTurn = turns[turns.length - 1]
if (previousTurn?.role !== "assistant") {
return false
}
// A continuation may itself pause, so turns after one stay continuations.
return (
previousTurn.resolve !== undefined ||
getPendingToolInputs(previousTurn.events).length > 0
)
}
function resolvePendingToolCalls(turnIndex: number, messages: MESSAGE[]) {
const previousIndex = findPreviousAssistantIndex(turnIndex)
const previousTurn = turns[previousIndex]
if (!previousTurn) {
return []
}
const previousEvents = previousTurn.resolve
? (previousTurn.lastEvents ??
materializeDeferredTurn(previousIndex, messages).events)
: previousTurn.events
const pendingInputs = getPendingToolInputs(previousEvents)
if (!pendingInputs.length) {
return []
}
const previousMessage = messages.find(
(message) => format.getMessageId(message) === previousTurn.messageId
)
const summaries =
previousMessage && format.getToolCalls
? format.getToolCalls(previousMessage)
: []
return pendingInputs.map((pendingInput) => ({
...pendingInput,
summary: summaries.find(
(summary) => summary.toolCallId === pendingInput.input.toolCallId
),
}))
}
function toPendingToolCall(
call: ReturnType<typeof resolvePendingToolCalls>[number]
) {
const approved = call.approval
? call.summary?.approval?.approved
: undefined
return {
name: call.input.name,
toolCallId: call.input.toolCallId,
input: call.summary?.input ?? call.input.input,
output: call.approval
? approved === true
? call.approval.output
: undefined
: call.summary?.state === "output-available"
? call.summary.output
: undefined,
...(call.approval
? {
approved: approved === true,
denied: approved === false,
}
: {}),
} as PendingToolCall<TOOLS>
}
function materializeDeferredTurn(
turnIndex: number,
messages: MESSAGE[]
): ResolvedTurn {
const turn = turns[turnIndex]
const resolve = turn.resolve
if (!resolve) {
throw new Error("Only continuation turns materialize from a transcript.")
}
const events = [...turn.events]
// The client merges a continuation into the prior assistant message as a
// new step. The step boundary keeps the resolved tool call out of the
// latest step so automatic sending does not retrigger on it.
events.push({ kind: "step-start" })
const pending = resolvePendingToolCalls(turnIndex, messages)
if (!pending.length) {
devWarn(
"A continuation turn resolved without a pending tool call. Its toolCall context is empty."
)
}
for (const call of pending) {
if (!call.approval || call.summary?.state !== "approval-responded") {
continue
}
if (call.summary.approval?.approved) {
if (call.approval.errorText !== undefined) {
events.push({
kind: "tool-error",
toolCallId: call.input.toolCallId,
errorText: call.approval.errorText,
providerExecuted: call.input.providerExecuted,
toolMetadata: call.input.toolMetadata,
dynamic: call.input.dynamic,
})
} else {
events.push({
kind: "tool-output",
toolCallId: call.input.toolCallId,
output: call.approval.output,
providerExecuted: call.input.providerExecuted,
toolMetadata: call.input.toolMetadata,
dynamic: call.input.dynamic,
})
}
} else {
events.push({
kind: "tool-denied",
toolCallId: call.input.toolCallId,
})
}
}
const toolCalls = pending.map(toPendingToolCall)
// Same cast point as materializeAssistantInput: the full writer passes
// through the adapter's narrower callback type.
const writer = createEventWriter(events, {
ids,
payloads,
}) as unknown as WRITER
resolve({
writer,
messages,
toolCall: toolCalls[toolCalls.length - 1],
toolCalls,
})
reserveEventIds(events)
turn.lastEvents = events
const parts = format.materializeParts(events)
const turnMetadata =
turn.metadata === undefined ? metadata() : cloneValue(turn.metadata)
return {
role: "assistant",
message: format.createMessage({
id: turn.messageId,
role: "assistant",
metadata: turnMetadata,
parts,
}),
events,
metadata: turnMetadata,
continuation: true,
}
}
const api: Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER> = {
user(text = DEFAULT_TEXT, userOptions: ChatUserOptions<METADATA> = {}) {
const events = takePendingEvents()
const turnEvents: ChatEvent<DATA, TOOLS>[] = []
turnEvents.push({
kind: "text",
id: `text-${turnEvents.length + 1}`,
text,
options: {
mode: "instant",
},
})
for (const file of userOptions.files ?? []) {
const { type: _type, ...part } = file
turnEvents.push({
kind: "file",
part: cloneValue(part),
})
}
const parts = format.materializeParts(turnEvents)
events.push(...turnEvents)
const turnMetadata =
userOptions.metadata === undefined
? metadata()
: cloneValue(userOptions.metadata)
const messageId = resolveMessageId(userOptions.id)
const userMessage = format.createMessage({
id: messageId,
role: "user",
metadata: turnMetadata,
parts,
})
return pushTurn({
role: "user",
message: userMessage,
messageId,
events,
metadata: turnMetadata,
})
},
assistant(
input:
| string
| PART[]
| ((
context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>
) => void) = DEFAULT_TEXT,
assistantOptions: ChatAssistantOptions<METADATA> = {}
) {
const events = takePendingEvents()
const continuation = isPreviousTurnPausedAssistant()
if (typeof input === "function" && continuation) {
return pushTurn({
role: "assistant",
messageId: resolveMessageId(assistantOptions.id),
events,
metadata:
assistantOptions.metadata === undefined
? undefined
: cloneValue(assistantOptions.metadata),
resolve: input,
continuation,
})
}
if (continuation) {
events.push({ kind: "step-start" })
}
const messageParts = materializeAssistantInput(input, events)
const turnMetadata =
assistantOptions.metadata === undefined
? metadata()
: cloneValue(assistantOptions.metadata)
const messageId = resolveMessageId(assistantOptions.id)
const assistantMessage = format.createMessage({
id: messageId,
role: "assistant",
metadata: turnMetadata,
parts: messageParts,
})
return pushTurn({
role: "assistant",
message: assistantMessage,
messageId,
events,
metadata: turnMetadata,
continuation: continuation || undefined,
})
},
error(errorText = "An error occurred.") {
const events = takePendingEvents()
events.push({
kind: "error",
errorText,
})
const turnMetadata = metadata()
const messageId = resolveMessageId()
return pushTurn({
role: "assistant",
message: format.createMessage({
id: messageId,
role: "assistant",
metadata: turnMetadata,
parts: [],
}),
messageId,
events,
metadata: turnMetadata,
})
},
sleep(delayMs: number) {
pendingEvents.push({
kind: "sleep",
delayMs,
phase: "before-start",
})
return api
},
get(count) {
const deferredIndex = turns.findIndex(
(turn) => turn.resolve !== undefined
)
const limit = deferredIndex === -1 ? turns.length : deferredIndex
if (count === undefined) {
count = limit
}
if (!Number.isInteger(count) || count < 0) {
throw new RangeError("count must be a non-negative integer.")
}
if (deferredIndex !== -1 && count > deferredIndex) {
throw new Error(
"get() cannot materialize a continuation turn without a live transcript."
)
}
return turns
.slice(0, count)
.map((turn) => cloneValue(turn.message as MESSAGE))
},
next(messages) {
const turn = findNextUserTurn(messages)
return turn?.message ? cloneValue(turn.message) : null
},
transport(
transportOptions: ChatTransportOptions<
MESSAGE,
PART,
DATA,
TOOLS,
WRITER
> = {}
) {
const lastAssistantIndex = findPreviousAssistantIndex(turns.length)
const lastAssistant = turns[lastAssistantIndex]
if (
lastAssistant &&
!lastAssistant.resolve &&
getPendingToolInputs(lastAssistant.events).some(
(pendingInput) => pendingInput.approval
)
) {
devWarn(
"The last scripted assistant turn requests approval but no continuation turn follows. The decision will have no response."
)
}
return format.createTransport(
{
resolveTurn(messages, messageId) {
const turnIndex = findNextAssistantTurnIndex(messages, messageId)
const turn = turnIndex === -1 ? undefined : turns[turnIndex]
if (turn) {
return turn.resolve
? materializeDeferredTurn(turnIndex, messages)
: (turn as ChatTurn<MESSAGE, DATA, TOOLS>)
}
if (transportOptions.fallback === undefined) {
return undefined
}
return createFallbackTurn(transportOptions.fallback, messages)
},
streamTurn(turn, encodeChunk, streamOptions = {}, abortSignal) {
const steps = lowerEvents<METADATA, DATA, TOOLS>(turn.events, {
delayMs: DEFAULT_STREAM_DELAY_MS,
...streamOptions,
// A continuation stream carries no message id: the client is
// already continuing the paused assistant message, and a new
// id would fork it into a duplicate instead of updating it.
messageId: (turn as ResolvedTurn).continuation
? undefined
: format.getMessageId(turn.message),
messageMetadata: (turn as ResolvedTurn).metadata,
})
return createTurnStream(steps, encodeChunk, abortSignal)
},
},
transportOptions
)
},
}
return api
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest"
import { createChatIds } from "./ids"
describe("createChatIds", () => {
it("generates sequential ids with default prefixes", () => {
const ids = createChatIds()
expect(ids.nextMessageId()).toBe("msg-1")
expect(ids.nextMessageId()).toBe("msg-2")
expect(ids.nextToolCallId()).toBe("call-1")
expect(ids.nextSourceId()).toBe("source-1")
expect(ids.nextApprovalId()).toBe("approval-1")
})
it("supports custom prefixes", () => {
const ids = createChatIds({
messageIdPrefix: "m",
toolCallIdPrefix: "t",
sourceIdPrefix: "s",
})
expect(ids.nextMessageId()).toBe("m-1")
expect(ids.nextToolCallId()).toBe("t-1")
expect(ids.nextSourceId()).toBe("s-1")
})
it("continues after explicitly reserved ids", () => {
const ids = createChatIds()
ids.reserveMessageId("msg-2")
ids.reserveToolCallId("call-4")
ids.reserveSourceId("source-3")
ids.reserveApprovalId("approval-2")
expect(ids.nextMessageId()).toBe("msg-3")
expect(ids.nextToolCallId()).toBe("call-5")
expect(ids.nextSourceId()).toBe("source-4")
expect(ids.nextApprovalId()).toBe("approval-3")
})
})

View File

@@ -0,0 +1,94 @@
import type { ChatIds, ChatIdsOptions } from "./types"
/**
* Creates deterministic sequential id generators (`msg-1`, `call-1`,
* `source-1`, …). A chat and its transport share one instance so ids never
* collide across scripted turns.
*/
export function createChatIds(options: ChatIdsOptions = {}): ChatIds {
const messageIdPrefix = options.messageIdPrefix ?? "msg"
const toolCallIdPrefix = options.toolCallIdPrefix ?? "call"
const sourceIdPrefix = options.sourceIdPrefix ?? "source"
const approvalIdPrefix = options.approvalIdPrefix ?? "approval"
function createSequence(prefix: string) {
const reserved = new Set<string>()
let index = 0
function reserve(id: string) {
reserved.add(id)
const prefixWithSeparator = `${prefix}-`
if (!id.startsWith(prefixWithSeparator)) {
return
}
const suffix = id.slice(prefixWithSeparator.length)
if (!/^\d+$/.test(suffix)) {
return
}
const observedIndex = Number(suffix)
if (Number.isSafeInteger(observedIndex)) {
index = Math.max(index, observedIndex)
}
}
function next() {
let id: string
do {
index += 1
id = `${prefix}-${index}`
} while (reserved.has(id))
reserve(id)
return id
}
return { next, reserve }
}
const messageIds = createSequence(messageIdPrefix)
const toolCallIds = createSequence(toolCallIdPrefix)
const sourceIds = createSequence(sourceIdPrefix)
const approvalIds = createSequence(approvalIdPrefix)
return {
nextMessageId() {
return messageIds.next()
},
nextToolCallId() {
return toolCallIds.next()
},
nextSourceId() {
return sourceIds.next()
},
nextApprovalId() {
return approvalIds.next()
},
reserveMessageId(id) {
messageIds.reserve(id)
},
reserveToolCallId(id) {
toolCallIds.reserve(id)
},
reserveSourceId(id) {
sourceIds.reserve(id)
},
reserveApprovalId(id) {
approvalIds.reserve(id)
},
}
}

View File

@@ -0,0 +1,8 @@
export * from "./types"
export * from "./ids"
export * from "./utils"
export * from "./payloads"
export * from "./writer"
export * from "./lower"
export * from "./stream"
export * from "./chat"

View File

@@ -0,0 +1,218 @@
import { describe, expect, it } from "vitest"
import { lowerEvents } from "./lower"
import { chunkSteps } from "./test-utils"
import type { TestData, TestTools } from "./test-utils"
import { splitTextDeltas } from "./utils"
describe("lowerEvents", () => {
it("places before-start sleeps ahead of the start chunk", () => {
const steps = lowerEvents(
[
{ kind: "sleep", delayMs: 100, phase: "before-start" },
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"sleep",
"start",
"text-start",
"text-delta",
"text-end",
"finish",
"close",
])
})
it("keeps later sleeps after the start chunk", () => {
const steps = lowerEvents(
[
{ kind: "step-start" },
{ kind: "sleep", delayMs: 100, phase: "after-start" },
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"start-step",
"sleep",
"finish",
"close",
])
})
it("omits delta sleeps when delayMs is undefined", () => {
const steps = lowerEvents(
[{ kind: "text", id: "text-1", text: "Hello there world" }],
{ delayMs: undefined }
)
expect(chunkSteps(steps)).toEqual([
"start",
"text-start",
"text-delta",
"text-delta",
"text-delta",
"text-end",
"finish",
"close",
])
})
it("splits streamed text into word deltas with sleeps", () => {
const steps = lowerEvents(
[{ kind: "text", id: "text-1", text: "Hello there world" }],
{ delayMs: 25 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"text-start",
"sleep",
"text-delta",
"sleep",
"text-delta",
"sleep",
"text-delta",
"text-end",
"finish",
"close",
])
expect(
steps.flatMap((step) =>
step.kind === "chunk" && step.chunk.type === "text-delta"
? [step.chunk.delta]
: []
)
).toEqual(splitTextDeltas("Hello there world"))
})
it("emits a single delta in instant mode and none of the word sleeps when delay is zero", () => {
const steps = lowerEvents(
[
{
kind: "reasoning",
id: "reasoning-1",
text: "Thinking about it",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"reasoning-start",
"reasoning-delta",
"reasoning-end",
"finish",
"close",
])
})
it("honors per-event delay overrides", () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant", delayMs: 10 },
},
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"text-start",
"sleep",
"text-delta",
"text-end",
"finish",
"close",
])
})
it("closes after an error chunk without a finish", () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
{ kind: "error", errorText: "Boom." },
{ kind: "step-start" },
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"text-start",
"text-delta",
"text-end",
"error",
"close",
])
})
it("carries message metadata on the finish chunk", () => {
const steps = lowerEvents([], {
delayMs: 0,
messageMetadata: { model: "gpt-5" },
})
const finish = steps.find(
(step) => step.kind === "chunk" && step.chunk.type === "finish"
)
expect(finish).toMatchObject({
chunk: {
type: "finish",
finishReason: "stop",
messageMetadata: { model: "gpt-5" },
},
})
})
it("maps tool events to tool chunks", () => {
const steps = lowerEvents<unknown, TestData, TestTools>(
[
{
kind: "tool-input",
name: "getWeather",
toolCallId: "call-1",
input: { city: "SF" },
},
{
kind: "tool-output",
toolCallId: "call-1",
output: { temperature: 72 },
},
{ kind: "tool-error", toolCallId: "call-1", errorText: "Down." },
{ kind: "tool-denied", toolCallId: "call-1" },
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"tool-input-available",
"tool-output-available",
"tool-output-error",
"tool-output-denied",
"finish",
"close",
])
})
})

View File

@@ -0,0 +1,298 @@
import type {
ChatEvent,
DataTypes,
NeutralChunk,
StreamStep,
ToolSet,
} from "./types"
import { assertNever, splitTextDeltas } from "./utils"
/**
* Options for {@link lowerEvents}. An `undefined` `delayMs` disables
* delta delays entirely; `messageId` rides on the `start` chunk and
* `messageMetadata` on the `finish` chunk.
*/
export type LowerEventsOptions<METADATA> = {
delayMs?: number
messageId?: string
messageMetadata?: METADATA
}
function lowerTextEvent<
METADATA,
DATA extends DataTypes,
TOOLS extends ToolSet,
>(
steps: StreamStep<METADATA, DATA, TOOLS>[],
event: Extract<ChatEvent<DATA, TOOLS>, { kind: "text" | "reasoning" }>,
options: LowerEventsOptions<METADATA>
) {
const startType = event.kind === "text" ? "text-start" : "reasoning-start"
const deltaType = event.kind === "text" ? "text-delta" : "reasoning-delta"
const endType = event.kind === "text" ? "text-end" : "reasoning-end"
steps.push({
kind: "chunk",
chunk: {
type: startType,
id: event.id,
} as NeutralChunk<METADATA, DATA, TOOLS>,
})
const deltas =
event.options?.mode === "instant"
? [event.text]
: splitTextDeltas(event.text)
const delayMs = event.options?.delayMs ?? options.delayMs
for (const delta of deltas) {
if (delayMs) {
steps.push({
kind: "sleep",
delayMs,
})
}
steps.push({
kind: "chunk",
chunk: {
type: deltaType,
id: event.id,
delta,
} as NeutralChunk<METADATA, DATA, TOOLS>,
})
}
steps.push({
kind: "chunk",
chunk: {
type: endType,
id: event.id,
} as NeutralChunk<METADATA, DATA, TOOLS>,
})
}
/**
* Lowers a turn's event log into playback steps: leading `before-start`
* sleeps precede the `start` chunk, text/reasoning split into
* start/delta/end with per-delta sleeps, an `error` event ends the stream
* without a `finish` chunk, and everything else maps to one chunk.
*/
export function lowerEvents<
METADATA,
DATA extends DataTypes,
TOOLS extends ToolSet,
>(
events: ChatEvent<DATA, TOOLS>[],
options: LowerEventsOptions<METADATA>
): StreamStep<METADATA, DATA, TOOLS>[] {
const steps: StreamStep<METADATA, DATA, TOOLS>[] = []
let eventIndex = 0
while (true) {
const event = events[eventIndex]
if (event?.kind !== "sleep" || event.phase !== "before-start") {
break
}
steps.push({
kind: "sleep",
delayMs: event.delayMs,
})
eventIndex += 1
}
steps.push({
kind: "chunk",
chunk: {
type: "start",
messageId: options.messageId,
},
})
for (const event of events.slice(eventIndex)) {
switch (event.kind) {
case "sleep": {
steps.push({
kind: "sleep",
delayMs: event.delayMs,
})
break
}
case "text":
case "reasoning": {
lowerTextEvent(steps, event, options)
break
}
case "data": {
steps.push({
kind: "chunk",
chunk: {
type: "data",
name: event.name,
id: event.id,
data: event.data,
transient: event.transient,
},
})
break
}
case "tool-input": {
steps.push({
kind: "chunk",
chunk: {
type: "tool-input-available",
toolCallId: event.toolCallId,
toolName: event.name,
input: event.input,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
dynamic: event.dynamic,
title: event.title,
},
})
break
}
case "tool-approval-request": {
// The gated output stays script-side; only the request goes on the wire.
steps.push({
kind: "chunk",
chunk: {
type: "tool-approval-request",
approvalId: event.approvalId,
toolCallId: event.toolCallId,
},
})
break
}
case "tool-output": {
steps.push({
kind: "chunk",
chunk: {
type: "tool-output-available",
toolCallId: event.toolCallId,
output: event.output,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
dynamic: event.dynamic,
},
})
break
}
case "tool-error": {
steps.push({
kind: "chunk",
chunk: {
type: "tool-output-error",
toolCallId: event.toolCallId,
errorText: event.errorText,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
dynamic: event.dynamic,
},
})
break
}
case "tool-denied": {
steps.push({
kind: "chunk",
chunk: {
type: "tool-output-denied",
toolCallId: event.toolCallId,
},
})
break
}
case "error": {
steps.push({
kind: "chunk",
chunk: {
type: "error",
errorText: event.errorText,
},
})
steps.push({
kind: "close",
})
return steps
}
case "source-url": {
steps.push({
kind: "chunk",
chunk: {
type: "source-url",
part: event.part,
},
})
break
}
case "source-document": {
steps.push({
kind: "chunk",
chunk: {
type: "source-document",
part: event.part,
},
})
break
}
case "file": {
steps.push({
kind: "chunk",
chunk: {
type: "file",
part: event.part,
},
})
break
}
case "reasoning-file": {
steps.push({
kind: "chunk",
chunk: {
type: "reasoning-file",
part: event.part,
},
})
break
}
case "custom": {
steps.push({
kind: "chunk",
chunk: {
type: "custom",
part: event.part,
},
})
break
}
case "step-start": {
steps.push({
kind: "chunk",
chunk: {
type: "start-step",
},
})
break
}
default:
return assertNever(event)
}
}
steps.push({
kind: "chunk",
chunk: {
type: "finish",
finishReason: "stop",
messageMetadata: options.messageMetadata,
},
})
steps.push({
kind: "close",
})
return steps
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest"
import { createChatIds } from "./ids"
import { createChatPayloads, createMetadataFactory } from "./payloads"
describe("createChatPayloads", () => {
it("creates default file payloads with overrides", () => {
const payloads = createChatPayloads(createChatIds())
expect(payloads.file()).toMatchObject({
mediaType: "image/png",
filename: "receipt.png",
url: "https://example.com/receipt.png",
})
expect(payloads.file({ filename: "report.pdf" })).toMatchObject({
filename: "report.pdf",
mediaType: "image/png",
})
})
it("assigns sequential source ids", () => {
const payloads = createChatPayloads(createChatIds())
expect(payloads.sourceUrl().sourceId).toBe("source-1")
expect(payloads.sourceDocument().sourceId).toBe("source-2")
expect(payloads.sourceUrl({ sourceId: "fixed" }).sourceId).toBe("fixed")
})
it("reserves explicit source ids and detaches nested metadata", () => {
const payloads = createChatPayloads(createChatIds())
const providerMetadata = { nested: { source: "original" } }
const source = payloads.sourceUrl({
sourceId: "source-2",
providerMetadata,
})
providerMetadata.nested.source = "mutated"
expect(payloads.sourceUrl().sourceId).toBe("source-3")
expect(source.providerMetadata).toEqual({
nested: { source: "original" },
})
})
it("creates custom payloads with a default kind", () => {
const payloads = createChatPayloads(createChatIds())
expect(payloads.custom()).toEqual({ kind: "test.output" })
expect(payloads.custom("app.widget")).toEqual({ kind: "app.widget" })
})
})
describe("createMetadataFactory", () => {
it("stamps a deterministic createdAt", () => {
const metadata = createMetadataFactory()
expect(metadata()).toEqual({
createdAt: "2026-01-01T00:00:00.000Z",
})
})
it("accepts a custom now and overrides", () => {
const metadata = createMetadataFactory<{
createdAt: string
model: string
}>("2025-06-15T12:00:00.000Z")
expect(metadata({ model: "gpt-5" })).toEqual({
createdAt: "2025-06-15T12:00:00.000Z",
model: "gpt-5",
})
})
})

View File

@@ -0,0 +1,106 @@
import type {
ChatIds,
CustomPayload,
FilePayload,
JsonRecord,
ReasoningFilePayload,
SourceDocumentPayload,
SourceUrlPayload,
} from "./types"
import { cloneValue } from "./utils"
/** Framework-neutral payload factories with sensible defaults for anything omitted. */
export type ChatPayloads = {
file(overrides?: Partial<FilePayload>): FilePayload
reasoningFile(overrides?: Partial<ReasoningFilePayload>): ReasoningFilePayload
sourceUrl(overrides?: Partial<SourceUrlPayload>): SourceUrlPayload
sourceDocument(
overrides?: Partial<SourceDocumentPayload>
): SourceDocumentPayload
custom(
kind?: string,
options?: { providerMetadata?: JsonRecord }
): CustomPayload
}
/**
* Creates the default payload factories. Source payloads draw their
* `sourceId` from the shared {@link ChatIds} sequence unless overridden.
*/
export function createChatPayloads(ids: ChatIds): ChatPayloads {
function resolveSourceId(sourceId?: string) {
if (sourceId !== undefined) {
ids.reserveSourceId(sourceId)
return sourceId
}
return ids.nextSourceId()
}
return {
file(overrides = {}) {
return {
mediaType: "image/png",
filename: "receipt.png",
url: "https://example.com/receipt.png",
...cloneValue(overrides),
}
},
reasoningFile(overrides = {}) {
return {
mediaType: "text/plain",
url: "data:text/plain;base64,cmVhc29uaW5n",
...cloneValue(overrides),
}
},
sourceUrl(overrides = {}) {
const clonedOverrides = cloneValue(overrides)
return {
sourceId: resolveSourceId(clonedOverrides.sourceId),
url: clonedOverrides.url ?? "https://ai-sdk.dev/docs",
title: clonedOverrides.title ?? "AI SDK Docs",
providerMetadata: clonedOverrides.providerMetadata,
}
},
sourceDocument(overrides = {}) {
const clonedOverrides = cloneValue(overrides)
return {
sourceId: resolveSourceId(clonedOverrides.sourceId),
mediaType: clonedOverrides.mediaType ?? "text/markdown",
title: clonedOverrides.title ?? "Retrieved document",
filename: clonedOverrides.filename,
providerMetadata: clonedOverrides.providerMetadata,
}
},
custom(kind = "test.output", options = {}) {
return {
kind,
...cloneValue(options),
}
},
}
}
/**
* Creates the default message-metadata factory: a fixed-clock
* `{ createdAt }` stamp (default `2026-01-01T00:00:00.000Z`) merged with any
* overrides, so snapshots stay stable across runs.
*/
export function createMetadataFactory<METADATA>(now?: Date | string) {
const resolvedNow =
now instanceof Date ? now : new Date(now ?? "2026-01-01T00:00:00.000Z")
return function metadata(overrides: Partial<METADATA> = {}) {
return {
createdAt: resolvedNow.toISOString(),
...cloneValue(overrides),
} as METADATA
}
}

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest"
import { lowerEvents } from "./lower"
import { createTurnStream } from "./stream"
import { readStream } from "./test-utils"
describe("createTurnStream", () => {
it("plays chunks through the encoder in order", async () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const chunks = await readStream(
createTurnStream(steps, (chunk) => ({ encoded: chunk.type }))
)
expect(chunks).toEqual([
{ encoded: "start" },
{ encoded: "text-start" },
{ encoded: "text-delta" },
{ encoded: "text-end" },
{ encoded: "finish" },
])
})
it("skips chunks when the encoder returns null and expands arrays", async () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const chunks = await readStream(
createTurnStream(steps, (chunk) => {
if (chunk.type === "finish") {
return null
}
if (chunk.type === "text-delta") {
return [chunk.type, chunk.type]
}
return chunk.type
})
)
expect(chunks).toEqual([
"start",
"text-start",
"text-delta",
"text-delta",
"text-end",
])
})
it("waits for sleep steps", async () => {
const steps = lowerEvents(
[
{ kind: "sleep", delayMs: 60, phase: "before-start" },
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const startedAt = Date.now()
const chunks = await readStream(
createTurnStream(steps, (chunk) => chunk.type)
)
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(40)
expect(chunks[0]).toBe("start")
})
it("closes silently when aborted during before-start sleeps", async () => {
const steps = lowerEvents(
[{ kind: "sleep", delayMs: 500, phase: "before-start" }],
{
delayMs: 0,
}
)
const controller = new AbortController()
const startedAt = Date.now()
const streamPromise = readStream(
createTurnStream(steps, (chunk) => chunk.type, controller.signal)
)
setTimeout(() => controller.abort(), 10)
expect(await streamPromise).toEqual([])
expect(Date.now() - startedAt).toBeLessThan(250)
})
it("closes without chunks when the signal is already aborted", async () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const controller = new AbortController()
controller.abort()
const chunks = await readStream(
createTurnStream(steps, (chunk) => chunk.type, controller.signal)
)
expect(chunks).toEqual([])
})
it("stops encoding after the consumer cancels the stream", async () => {
const encoded: string[] = []
const stream = createTurnStream(
[
{ kind: "chunk", chunk: { type: "start" } },
{ kind: "sleep", delayMs: 30 },
{ kind: "chunk", chunk: { type: "text-start", id: "text-1" } },
{ kind: "chunk", chunk: { type: "text-end", id: "text-1" } },
{ kind: "close" },
],
(chunk) => {
encoded.push(chunk.type)
return chunk.type
}
)
const reader = stream.getReader()
expect((await reader.read()).value).toBe("start")
await reader.cancel()
await new Promise((resolve) => setTimeout(resolve, 60))
expect(encoded).toEqual(["start"])
})
it("emits an abort chunk when aborted mid-stream", async () => {
const steps = lowerEvents(
[
{ kind: "sleep", delayMs: 500, phase: "after-start" },
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const controller = new AbortController()
const startedAt = Date.now()
const streamPromise = readStream(
createTurnStream(steps, (chunk) => chunk.type, controller.signal)
)
setTimeout(() => controller.abort(), 10)
expect(await streamPromise).toEqual(["start", "abort"])
expect(Date.now() - startedAt).toBeLessThan(250)
})
})

View File

@@ -0,0 +1,116 @@
import type {
ChunkEncoder,
DataTypes,
NeutralChunk,
StreamStep,
ToolSet,
} from "./types"
import { wait } from "./utils"
/**
* Plays lowered steps through an encoder into a `ReadableStream`. Honors
* sleep steps, skips chunks the encoder maps to `null`, fans out arrays, and
* handles aborts: before the `start` chunk the stream closes silently, after
* it an `abort` chunk is emitted first.
*/
export function createTurnStream<
CHUNK,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
>(
steps: StreamStep<METADATA, DATA, TOOLS>[],
encode: ChunkEncoder<CHUNK, METADATA, DATA, TOOLS>,
abortSignal?: AbortSignal
): ReadableStream<CHUNK> {
let cancelled = false
return new ReadableStream<CHUNK>({
async start(controller) {
let aborted = abortSignal?.aborted ?? false
const abort = () => {
aborted = true
}
abortSignal?.addEventListener("abort", abort)
function enqueue(chunk: NeutralChunk<METADATA, DATA, TOOLS>) {
const encoded = encode(chunk)
if (encoded === null) {
return
}
if (Array.isArray(encoded)) {
for (const item of encoded) {
controller.enqueue(item)
}
return
}
controller.enqueue(encoded)
}
try {
let started = false
function closeAfterAbort() {
if (started) {
enqueue({
type: "abort",
reason: "aborted",
})
}
controller.close()
}
for (const step of steps) {
// The consumer already tore the stream down — enqueueing or
// closing a cancelled stream would throw.
if (cancelled) {
return
}
if (aborted) {
closeAfterAbort()
return
}
if (step.kind === "sleep") {
await wait(step.delayMs, abortSignal)
if (aborted) {
closeAfterAbort()
return
}
continue
}
if (step.kind === "close") {
controller.close()
return
}
enqueue(step.chunk)
if (step.chunk.type === "start") {
started = true
}
}
if (!cancelled) {
controller.close()
}
} finally {
abortSignal?.removeEventListener("abort", abort)
}
},
cancel() {
cancelled = true
},
})
}

View File

@@ -0,0 +1,231 @@
import { createChatRuntime } from "./chat"
import type {
ChatEvent,
ChatFormat,
DataTypes,
MessageRole,
NeutralChunk,
StreamStep,
ToolCallSummary,
ToolSet,
TransportContext,
TurnStreamOptions,
} from "./types"
export type TestData = {
weather: {
city: string
status: "loading" | "success"
}
}
export type TestTools = {
getWeather: {
input: {
city: string
}
output: {
temperature: number
}
}
}
export type TestPart = {
type: string
[key: string]: unknown
}
export type TestMessage = {
id: string
role: MessageRole
metadata?: unknown
parts: TestPart[]
}
export type TestTransport<
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = {
context: TransportContext<
TestMessage,
NeutralChunk<unknown, DATA, TOOLS>,
unknown,
DATA,
TOOLS
>
options?: TurnStreamOptions
}
export function createTestFormat<
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
>(): ChatFormat<
TestMessage,
TestPart,
NeutralChunk<unknown, DATA, TOOLS>,
TestTransport<DATA, TOOLS>,
unknown,
DATA,
TOOLS
> {
return {
materializeParts(events) {
const parts: TestPart[] = []
for (const event of events) {
if (event.kind === "text") {
parts.push({ type: "text", text: event.text })
}
if (event.kind === "reasoning") {
parts.push({ type: "reasoning", text: event.text })
}
if (event.kind === "data" && !event.transient) {
parts.push({
type: `data-${event.name}`,
id: event.id,
data: event.data,
})
}
if (event.kind === "tool-input") {
parts.push({
type: `tool-${event.name}`,
toolCallId: event.toolCallId,
state: "input-available",
input: event.input,
})
}
if (event.kind === "tool-approval-request") {
const index = parts.findIndex(
(part) => part.toolCallId === event.toolCallId
)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "approval-requested",
approval: { id: event.approvalId },
}
}
}
if (event.kind === "tool-output") {
const index = parts.findIndex(
(part) => part.toolCallId === event.toolCallId
)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "output-available",
output: event.output,
}
}
}
if (event.kind === "file") {
parts.push({ type: "file", ...event.part })
}
}
return parts
},
eventsFromParts(parts) {
const events: ChatEvent<DATA, TOOLS>[] = []
for (const part of parts) {
if (part.type === "text") {
events.push({
kind: "text",
id: `text-${events.length + 1}`,
text: part.text as string,
options: {
mode: "instant",
},
})
}
}
return events
},
createMessage(input) {
return { ...input }
},
getMessageId(message) {
return message.id
},
getMessageRole(message) {
return message.role
},
getMessageText(message) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
},
getMessageParts(message) {
return message.parts
},
getToolCalls(message) {
return message.parts
.filter((part) => part.type.startsWith("tool-"))
.map((part) => ({
toolCallId: part.toolCallId as string,
name: part.type.replace("tool-", ""),
input: part.input,
output: part.state === "output-available" ? part.output : undefined,
state: part.state as ToolCallSummary["state"],
approval: part.approval as ToolCallSummary["approval"],
}))
},
createTransport(context, options) {
return { context, options }
},
}
}
export function createTestChat() {
return createChatRuntime<
TestMessage,
TestPart,
NeutralChunk<unknown, TestData, TestTools>,
TestTransport<TestData, TestTools>,
unknown,
TestData,
TestTools
>(createTestFormat<TestData, TestTools>())
}
export async function readStream<T>(stream: ReadableStream<T>) {
const chunks = []
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
}
return chunks
}
export function chunkSteps(steps: StreamStep[]) {
return steps.map((step) =>
step.kind === "chunk" ? step.chunk.type : step.kind
)
}

View File

@@ -0,0 +1,404 @@
/** A JSON-like object used for framework provider metadata. */
export type JsonRecord = Record<string, unknown>
/** Message roles supported by the framework-neutral chat model. */
export type MessageRole = "system" | "user" | "assistant"
/** Map of data part names to their payload shapes, e.g. `{ weather: { temp: number } }`. */
export type DataTypes = Record<string, unknown>
/** The input and output shapes associated with one scripted tool. */
export type ToolDefinition = {
input: unknown
output: unknown
}
/** Map of tool names to their `input`/`output` shapes. Structurally compatible with the AI SDK's `UITools`. */
export type ToolSet = Record<string, ToolDefinition>
/** Options for scripting a typed or dynamic tool call. */
export type ToolWriterOptions<
TOOLS extends ToolSet,
NAME extends keyof TOOLS,
> = {
toolCallId?: string
title?: string
toolMetadata?: JsonRecord
providerExecuted?: boolean
input?: TOOLS[NAME]["input"]
output?: TOOLS[NAME]["output"]
errorText?: string
/** Script the call as a `dynamic-tool` part instead of a typed `tool-<name>` part. */
dynamic?: boolean
/**
* Pause the turn behind a user approval. `output` (or `errorText`) then
* means "stream this after approval" instead of "resolve immediately";
* denial streams `tool-output-denied` automatically.
*/
needsApproval?: boolean
approvalId?: string
}
/**
* A human-in-the-loop tool call as a continuation turn sees it: the scripted
* input, the user-submitted output (elicitation) or gated output (approval),
* and the approval decision when one was requested.
*/
export type PendingToolCall<TOOLS extends ToolSet> = {
[NAME in keyof TOOLS & string]: {
name: NAME
toolCallId: string
input: TOOLS[NAME]["input"]
output: TOOLS[NAME]["output"] | undefined
approved?: boolean
denied?: boolean
}
}[keyof TOOLS & string]
/**
* A framework-neutral summary of one tool call read back from a transcript
* message. Adapters produce these through `ChatFormat.getToolCalls`.
*/
export type ToolCallSummary = {
toolCallId: string
name: string
dynamic?: boolean
input: unknown
output?: unknown
errorText?: string
state:
| "input-streaming"
| "input-available"
| "approval-requested"
| "approval-responded"
| "output-available"
| "output-error"
| "output-denied"
| "undefined-input"
approval?: { id: string; approved?: boolean; reason?: string }
}
/**
* Per-part streaming controls. `delayMs` overrides the transport delay for
* this part; `mode: "instant"` emits the whole text as a single delta instead
* of word-by-word.
*/
export type StreamTextOptions = {
id?: string
delayMs?: number
mode?: "stream" | "instant"
}
/** Framework-neutral file attachment payload. */
export type FilePayload = {
mediaType: string
filename?: string
url: string
providerMetadata?: JsonRecord
}
/** Framework-neutral file attached to a reasoning part. */
export type ReasoningFilePayload = {
mediaType: string
filename?: string
url: string
providerMetadata?: JsonRecord
}
/** Framework-neutral URL source citation. */
export type SourceUrlPayload = {
sourceId: string
url: string
title?: string
providerMetadata?: JsonRecord
}
/** Framework-neutral document source citation. */
export type SourceDocumentPayload = {
sourceId: string
mediaType: string
title: string
filename?: string
providerMetadata?: JsonRecord
}
/** Framework-neutral custom part payload. */
export type CustomPayload = {
kind: string
providerMetadata?: JsonRecord
}
/**
* A typed `data-<name>` part input. Repeating an `id` updates the previous
* part in place (reconciliation); `transient` parts stream but never
* materialize into message parts.
*/
export type DataEventInput<DATA extends DataTypes> = {
[NAME in keyof DATA & string]: {
type: `data-${NAME}`
id?: string
data: DATA[NAME]
transient?: boolean
}
}[keyof DATA & string]
/**
* One entry in a turn's event log — the framework-neutral source of truth a
* turn is scripted as. Adapters materialize events into message parts and
* the stream driver lowers them into chunks.
*/
export type ChatEvent<
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> =
| { kind: "sleep"; delayMs: number; phase: "before-start" | "after-start" }
| { kind: "text"; id: string; text: string; options?: StreamTextOptions }
| { kind: "reasoning"; id: string; text: string; options?: StreamTextOptions }
| {
kind: "data"
name: keyof DATA & string
id?: string
data: DATA[keyof DATA & string]
transient?: boolean
}
| {
kind: "tool-input"
name: keyof TOOLS & string
toolCallId: string
title?: string
toolMetadata?: JsonRecord
providerExecuted?: boolean
dynamic?: boolean
input: unknown
}
| {
kind: "tool-approval-request"
toolCallId: string
approvalId: string
output?: unknown
errorText?: string
}
| {
kind: "tool-output"
toolCallId: string
output: unknown
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
}
| {
kind: "tool-error"
toolCallId: string
errorText: string
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
}
| { kind: "tool-denied"; toolCallId: string }
| { kind: "error"; errorText: string }
| { kind: "source-url"; part: SourceUrlPayload }
| { kind: "source-document"; part: SourceDocumentPayload }
| { kind: "file"; part: FilePayload }
| { kind: "reasoning-file"; part: ReasoningFilePayload }
| { kind: "custom"; part: CustomPayload }
| { kind: "step-start" }
/**
* The framework-neutral stream chunk vocabulary. An adapter's
* {@link ChunkEncoder} maps each chunk to its framework's wire format —
* return `null` to skip a chunk the framework has no equivalent for.
*/
export type NeutralChunk<
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> =
| { type: "start"; messageId?: string }
| { type: "start-step" }
| { type: "finish"; finishReason: "stop"; messageMetadata?: METADATA }
| { type: "abort"; reason: string }
| { type: "error"; errorText: string }
| { type: "text-start"; id: string }
| { type: "text-delta"; id: string; delta: string }
| { type: "text-end"; id: string }
| { type: "reasoning-start"; id: string }
| { type: "reasoning-delta"; id: string; delta: string }
| { type: "reasoning-end"; id: string }
| {
type: "data"
name: keyof DATA & string
id?: string
data: DATA[keyof DATA & string]
transient?: boolean
}
| {
type: "tool-input-available"
toolCallId: string
toolName: keyof TOOLS & string
input: unknown
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
title?: string
}
| {
type: "tool-approval-request"
approvalId: string
toolCallId: string
}
| {
type: "tool-output-available"
toolCallId: string
output: unknown
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
}
| {
type: "tool-output-error"
toolCallId: string
errorText: string
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
}
| { type: "tool-output-denied"; toolCallId: string }
| { type: "file"; part: FilePayload }
| { type: "reasoning-file"; part: ReasoningFilePayload }
| { type: "source-url"; part: SourceUrlPayload }
| { type: "source-document"; part: SourceDocumentPayload }
| { type: "custom"; part: CustomPayload }
/** One playback instruction produced by `lowerEvents`: emit a chunk, sleep, or close the stream. */
export type StreamStep<
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> =
| { kind: "sleep"; delayMs: number }
| { kind: "chunk"; chunk: NeutralChunk<METADATA, DATA, TOOLS> }
| { kind: "close" }
/** A scripted turn: the materialized message plus the event log it streams from. */
export type ChatTurn<
MESSAGE,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = {
role: MessageRole
message: MESSAGE
events: ChatEvent<DATA, TOOLS>[]
}
/** Deterministic sequential id generators (`msg-1`, `call-1`, `source-1`, …). */
export type ChatIds = {
nextMessageId(): string
nextToolCallId(): string
nextSourceId(): string
nextApprovalId(): string
reserveMessageId(id: string): void
reserveToolCallId(id: string): void
reserveSourceId(id: string): void
reserveApprovalId(id: string): void
}
/** Prefixes used by the deterministic chat id generators. */
export type ChatIdsOptions = {
messageIdPrefix?: string
toolCallIdPrefix?: string
sourceIdPrefix?: string
approvalIdPrefix?: string
}
/** Chat-wide options: id prefixes plus the fixed clock used for default metadata. */
export type ChatOptions = ChatIdsOptions & {
now?: Date | string
}
/** Transport options. `delayMs` is the delay between text/reasoning deltas; defaults to 50ms. */
export type TurnStreamOptions = {
delayMs?: number
}
/**
* Maps a {@link NeutralChunk} to a framework chunk. Return `null` to skip
* the chunk or an array to fan one neutral chunk out into several.
*/
export type ChunkEncoder<
CHUNK,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = (chunk: NeutralChunk<METADATA, DATA, TOOLS>) => CHUNK | CHUNK[] | null
/**
* What the core hands an adapter's `createTransport`: `resolveTurn` matches a
* transcript to the next scripted assistant turn, `streamTurn` plays that
* turn as a chunk stream.
*/
export type TransportContext<
MESSAGE,
CHUNK,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = {
resolveTurn(
messages: MESSAGE[],
messageId?: string
): ChatTurn<MESSAGE, DATA, TOOLS> | undefined
streamTurn(
turn: ChatTurn<MESSAGE, DATA, TOOLS>,
encodeChunk: ChunkEncoder<CHUNK, METADATA, DATA, TOOLS>,
options?: TurnStreamOptions,
abortSignal?: AbortSignal
): ReadableStream<CHUNK>
}
/**
* The adapter plugin interface. Implement one of these per framework and pass
* it to `createChatRuntime` — the core never imports a framework itself.
*/
export type ChatFormat<
MESSAGE,
PART,
CHUNK,
TRANSPORT,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = {
materializeParts(events: ChatEvent<DATA, TOOLS>[]): PART[]
eventsFromParts(parts: PART[]): ChatEvent<DATA, TOOLS>[]
createMessage(input: {
id: string
role: MessageRole
metadata: METADATA
parts: PART[]
}): MESSAGE
getMessageId(message: MESSAGE): string
getMessageRole(message: MESSAGE): MessageRole
getMessageText(message: MESSAGE): string
getMessageParts(message: MESSAGE): PART[]
/** Optional: extract a message's metadata when hydrating chats from existing messages. */
getMessageMetadata?(message: MESSAGE): METADATA | undefined
/** Optional: read a message's tool calls so continuation turns can resolve human-in-the-loop state. */
getToolCalls?(message: MESSAGE): ToolCallSummary[]
createTransport(
context: TransportContext<MESSAGE, CHUNK, METADATA, DATA, TOOLS>,
options?: TurnStreamOptions
): TRANSPORT
}
/** Options for one user turn. */
export type ChatUserOptions<METADATA> = {
id?: string
metadata?: METADATA
files?: Array<FilePayload & { type?: "file" }>
}
/** Options for one assistant turn. */
export type ChatAssistantOptions<METADATA> = {
id?: string
metadata?: METADATA
}

View File

@@ -0,0 +1,55 @@
/** Sample text used when `text()`/`user()` is called without content. */
export const DEFAULT_TEXT = "Summarize the uploaded receipt."
/** Sample reasoning used when `reasoning()` is called without content. */
export const DEFAULT_REASONING =
"I need to inspect the available context before answering."
/** Default delay between streamed text/reasoning deltas. */
export const DEFAULT_STREAM_DELAY_MS = 50
/** Compile-time exhaustiveness guard for discriminated unions we own. */
export function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`)
}
/** Resolves after `delayMs`, or immediately when `abortSignal` aborts. */
export function wait(delayMs: number, abortSignal?: AbortSignal) {
if (abortSignal?.aborted) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const timeoutId = setTimeout(finish, delayMs)
function finish() {
clearTimeout(timeoutId)
abortSignal?.removeEventListener("abort", finish)
resolve()
}
abortSignal?.addEventListener("abort", finish, { once: true })
})
}
/** Returns a detached copy of a scripted value. */
export function cloneValue<T>(value: T): T {
return structuredClone(value)
}
/** Splits text into word-plus-whitespace deltas for streaming. */
export function splitTextDeltas(text: string) {
return text.match(/\S+\s*/g) ?? [text]
}
/** Extracts the data part name from a `data-<name>` type string. */
export function getDataPartName(type: `data-${string}`) {
return type.slice("data-".length)
}
/** Logs a development-only warning. */
export function devWarn(message: string) {
if (process.env.NODE_ENV !== "production") {
console.warn(`[helpers] ${message}`)
}
}

View File

@@ -0,0 +1,214 @@
import { describe, expect, it } from "vitest"
import { createChatIds } from "./ids"
import { createChatPayloads } from "./payloads"
import type { TestData, TestTools } from "./test-utils"
import type { ChatEvent } from "./types"
import { DEFAULT_REASONING, DEFAULT_TEXT } from "./utils"
import { createEventWriter } from "./writer"
describe("createEventWriter", () => {
function createWriterContext() {
const ids = createChatIds()
return { ids, payloads: createChatPayloads(ids) }
}
it("records text and reasoning events with generated part ids", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer.text().reasoning()
expect(events).toMatchObject([
{
kind: "text",
id: "text-1",
text: DEFAULT_TEXT,
},
{
kind: "reasoning",
id: "reasoning-2",
text: DEFAULT_REASONING,
},
])
})
it("respects explicit stream text ids", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer.text("Hello", { id: "greeting" })
expect(events[0]).toMatchObject({
kind: "text",
id: "greeting",
})
})
it("records sleeps as after-start events", () => {
const events: ChatEvent<TestData, TestTools>[] = []
createEventWriter(events, createWriterContext()).sleep(100)
expect(events).toEqual([
{
kind: "sleep",
delayMs: 100,
phase: "after-start",
},
])
})
it("extracts data part names", () => {
const events: ChatEvent<TestData, TestTools>[] = []
createEventWriter(events, createWriterContext()).data({
type: "data-weather",
id: "weather-1",
data: { city: "SF", status: "loading" },
transient: true,
})
expect(events[0]).toMatchObject({
kind: "data",
name: "weather",
id: "weather-1",
transient: true,
})
})
it("chains tool events through the handle", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer
.tool("getWeather", { input: { city: "SF" } })
.sleep(50)
.output({ temperature: 72 })
expect(events.map((event) => event.kind)).toEqual([
"tool-input",
"sleep",
"tool-output",
])
expect(events[0]).toMatchObject({
kind: "tool-input",
name: "getWeather",
toolCallId: "call-1",
input: { city: "SF" },
})
expect(events[2]).toMatchObject({
kind: "tool-output",
toolCallId: "call-1",
output: { temperature: 72 },
})
})
it("reserves explicit tool ids and detaches tool values", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const context = createWriterContext()
const input = { city: "SF" }
const output = { temperature: 72 }
const writer = createEventWriter(events, context)
writer.tool("getWeather", { toolCallId: "call-2", input }).output(output)
writer.tool("getWeather", { input: { city: "NYC" } })
input.city = "mutated"
output.temperature = 0
expect(events).toMatchObject([
{ toolCallId: "call-2", input: { city: "SF" } },
{ toolCallId: "call-2", output: { temperature: 72 } },
{ toolCallId: "call-3" },
])
})
it("types tool inputs and outputs", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
if (false) {
writer.tool("getWeather", {
// @ts-expect-error getWeather input requires a string city.
input: { city: 72 },
})
writer.tool("getWeather", {
// @ts-expect-error getWeather output requires a numeric temperature.
output: { temperature: "warm" },
})
}
expect(events).toEqual([])
})
it("emits tool output and error from writer options", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer.tool("getWeather", {
input: { city: "SF" },
output: { temperature: 72 },
})
writer.tool("getWeather", {
input: { city: "SF" },
errorText: "Service down.",
})
expect(events.map((event) => event.kind)).toEqual([
"tool-input",
"tool-output",
"tool-input",
"tool-error",
])
})
it("records denied tool calls and defaults tool input to an empty object", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer.tool("getWeather").denied()
expect(events).toMatchObject([
{
kind: "tool-input",
input: {},
},
{
kind: "tool-denied",
toolCallId: "call-1",
},
])
})
it("records payload-backed events", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer
.sourceUrl()
.sourceDocument()
.file({ filename: "notes.txt" })
.reasoningFile()
.custom("app.widget")
.stepStart()
.error()
expect(events.map((event) => event.kind)).toEqual([
"source-url",
"source-document",
"file",
"reasoning-file",
"custom",
"step-start",
"error",
])
expect(events[0]).toMatchObject({
part: { sourceId: "source-1" },
})
expect(events[2]).toMatchObject({
part: { filename: "notes.txt" },
})
})
})

View File

@@ -0,0 +1,303 @@
import type { ChatPayloads } from "./payloads"
import type {
ChatEvent,
ChatIds,
DataEventInput,
DataTypes,
FilePayload,
ReasoningFilePayload,
SourceDocumentPayload,
SourceUrlPayload,
StreamTextOptions,
ToolSet,
ToolWriterOptions,
} from "./types"
import {
cloneValue,
DEFAULT_REASONING,
DEFAULT_TEXT,
getDataPartName,
} from "./utils"
/**
* Lifecycle handle returned by `writer.tool()`. Chain `sleep()` between the
* input and its `output()`/`error()`/`denied()` resolution to script tool
* latency.
*/
export type ToolHandle<OUTPUT> = {
sleep(delayMs: number): ToolHandle<OUTPUT>
output(output: OUTPUT): ToolHandle<OUTPUT>
error(errorText?: string): ToolHandle<OUTPUT>
denied(): ToolHandle<OUTPUT>
}
/**
* The fluent writer DSL handed to `assistant(({ writer }) => …)`. Each call
* appends events to the turn's log in order; every method chains except
* `tool()`, which returns a {@link ToolHandle}.
*/
export type EventWriter<DATA extends DataTypes, TOOLS extends ToolSet> = {
text(text?: string, options?: StreamTextOptions): EventWriter<DATA, TOOLS>
reasoning(
text?: string,
options?: StreamTextOptions
): EventWriter<DATA, TOOLS>
sleep(delayMs: number): EventWriter<DATA, TOOLS>
data(part: DataEventInput<DATA>): EventWriter<DATA, TOOLS>
error(errorText?: string): EventWriter<DATA, TOOLS>
tool<NAME extends keyof TOOLS & string>(
name: NAME,
options?: ToolWriterOptions<TOOLS, NAME>
): ToolHandle<TOOLS[NAME]["output"]>
sourceUrl(overrides?: Partial<SourceUrlPayload>): EventWriter<DATA, TOOLS>
sourceDocument(
overrides?: Partial<SourceDocumentPayload>
): EventWriter<DATA, TOOLS>
file(overrides?: Partial<FilePayload>): EventWriter<DATA, TOOLS>
reasoningFile(
overrides?: Partial<ReasoningFilePayload>
): EventWriter<DATA, TOOLS>
custom(kind?: string): EventWriter<DATA, TOOLS>
stepStart(): EventWriter<DATA, TOOLS>
}
/**
* Creates an {@link EventWriter} that appends to `events`. Generated part ids
* are `<kind>-<events.length + 1>` at push time, matching the reference
* implementation this package was extracted from.
*/
export function createEventWriter<
DATA extends DataTypes,
TOOLS extends ToolSet,
>(
events: ChatEvent<DATA, TOOLS>[],
context: { ids: ChatIds; payloads: ChatPayloads }
): EventWriter<DATA, TOOLS> {
function nextPartId(kind: string) {
return `${kind}-${events.length + 1}`
}
const writer: EventWriter<DATA, TOOLS> = {
text(text = DEFAULT_TEXT, options: StreamTextOptions = {}) {
events.push({
kind: "text",
id: options.id ?? nextPartId("text"),
text,
options,
})
return writer
},
reasoning(text = DEFAULT_REASONING, options: StreamTextOptions = {}) {
events.push({
kind: "reasoning",
id: options.id ?? nextPartId("reasoning"),
text,
options,
})
return writer
},
sleep(delayMs: number) {
events.push({
kind: "sleep",
delayMs,
phase: "after-start",
})
return writer
},
data(part) {
const name = getDataPartName(part.type) as keyof DATA & string
const clonedPart = cloneValue(part)
events.push({
kind: "data",
name,
id: clonedPart.id,
data: clonedPart.data,
transient: clonedPart.transient,
} as ChatEvent<DATA, TOOLS>)
return writer
},
error(errorText = "An error occurred.") {
events.push({
kind: "error",
errorText,
})
return writer
},
tool<NAME extends keyof TOOLS & string>(
name: NAME,
options: ToolWriterOptions<TOOLS, NAME> = {}
) {
const clonedOptions = cloneValue(options)
const toolCallId =
clonedOptions.toolCallId ?? context.ids.nextToolCallId()
if (clonedOptions.toolCallId !== undefined) {
context.ids.reserveToolCallId(clonedOptions.toolCallId)
}
events.push({
kind: "tool-input",
name,
toolCallId,
title: clonedOptions.title,
toolMetadata: clonedOptions.toolMetadata,
providerExecuted: clonedOptions.providerExecuted,
dynamic: clonedOptions.dynamic,
input: clonedOptions.input ?? {},
})
function assertResolvable(method: string) {
if (clonedOptions.needsApproval) {
throw new Error(
`A needsApproval tool call resolves from the user's decision; remove the ${method}() call.`
)
}
}
const handle: ToolHandle<TOOLS[NAME]["output"]> = {
sleep(delayMs: number) {
events.push({
kind: "sleep",
delayMs,
phase: "after-start",
})
return handle
},
output(output: TOOLS[NAME]["output"]) {
assertResolvable("output")
events.push({
kind: "tool-output",
toolCallId,
output: cloneValue(output),
providerExecuted: clonedOptions.providerExecuted,
toolMetadata: clonedOptions.toolMetadata,
dynamic: clonedOptions.dynamic,
})
return handle
},
error(errorText = "Tool call failed.") {
assertResolvable("error")
events.push({
kind: "tool-error",
toolCallId,
errorText,
providerExecuted: clonedOptions.providerExecuted,
toolMetadata: clonedOptions.toolMetadata,
dynamic: clonedOptions.dynamic,
})
return handle
},
denied() {
assertResolvable("denied")
events.push({
kind: "tool-denied",
toolCallId,
})
return handle
},
}
if (clonedOptions.needsApproval) {
const approvalId =
clonedOptions.approvalId ?? context.ids.nextApprovalId()
if (clonedOptions.approvalId !== undefined) {
context.ids.reserveApprovalId(clonedOptions.approvalId)
}
events.push({
kind: "tool-approval-request",
toolCallId,
approvalId,
output: clonedOptions.output,
errorText: clonedOptions.errorText,
})
return handle
}
if (clonedOptions.output !== undefined) {
handle.output(clonedOptions.output)
}
if (clonedOptions.errorText !== undefined) {
handle.error(clonedOptions.errorText)
}
return handle
},
sourceUrl(overrides: Partial<SourceUrlPayload> = {}) {
events.push({
kind: "source-url",
part: context.payloads.sourceUrl(overrides),
})
return writer
},
sourceDocument(overrides: Partial<SourceDocumentPayload> = {}) {
events.push({
kind: "source-document",
part: context.payloads.sourceDocument(overrides),
})
return writer
},
file(overrides: Partial<FilePayload> = {}) {
events.push({
kind: "file",
part: context.payloads.file(overrides),
})
return writer
},
reasoningFile(overrides: Partial<ReasoningFilePayload> = {}) {
events.push({
kind: "reasoning-file",
part: context.payloads.reasoningFile(overrides),
})
return writer
},
custom(kind?: string) {
events.push({
kind: "custom",
part: context.payloads.custom(kind),
})
return writer
},
stepStart() {
events.push({
kind: "step-start",
})
return writer
},
}
return writer
}

View File

@@ -0,0 +1,103 @@
import type { UIMessage } from "@tanstack/ai-client"
import type { ClientTool } from "@tanstack/ai/client"
import { describe, expect, expectTypeOf, it } from "vitest"
import { testChatContract } from "../test/chat-contract"
import { createChat } from "./index"
import { weatherOutput } from "./test-utils"
type TestSchema<T> = {
readonly "~standard": {
readonly version: 1
readonly vendor: "test"
readonly types: {
readonly input: T
readonly output: T
}
readonly jsonSchema: {
readonly input: () => Record<string, unknown>
readonly output: () => Record<string, unknown>
}
}
}
type WeatherTools = readonly [
ClientTool<
TestSchema<{ city: string }>,
TestSchema<typeof weatherOutput>,
"getWeather"
>,
]
testChatContract("TanStack AI", () =>
createChat().user("One").assistant("A").user("Two").assistant("B")
)
describe("TanStack chat", () => {
it("returns native messages and exposes only supported writer operations", () => {
const chat = createChat<WeatherTools, { summary: string }>().assistant(
({ writer }) => {
writer
.tool("getWeather", { input: { city: "San Francisco" } })
.output(weatherOutput)
if (false) {
// @ts-expect-error TanStack's AG-UI transport has no data-part equivalent.
writer.data({ type: "data-weather", data: {} })
// @ts-expect-error TanStack's AG-UI transport has no source-part equivalent.
writer.sourceUrl()
// @ts-expect-error Only tools declared in the client tool tuple are accepted.
writer.tool("missing")
writer.tool("getWeather", {
// @ts-expect-error The declared input requires a string city.
input: { city: 72 },
})
// @ts-expect-error TanStack messages do not preserve text part IDs.
writer.text("Hello", { id: "text-1" })
writer.tool("getWeather", {
// @ts-expect-error TanStack messages do not preserve neutral tool titles.
title: "Weather",
})
}
}
)
const messages = chat.get()
expectTypeOf(messages).toEqualTypeOf<
Array<UIMessage<WeatherTools, { summary: string }>>
>()
expect(messages[0].parts[0]).toMatchObject({
type: "tool-call",
name: "getWeather",
})
if (false) {
// @ts-expect-error TanStack AI does not expose source parts.
createChat({ sourceIdPrefix: "source" })
}
})
it("hydrates a chat from an existing transcript", () => {
const source = createChat()
.user("What's the weather?")
.assistant(({ writer }) => {
writer
.tool("getWeather", {
input: { city: "San Francisco" },
})
.output(weatherOutput)
writer.text("It's sunny.", { mode: "instant" })
})
.user("Thanks!")
.assistant("Anytime.")
const recorded = source.get()
const replay = createChat({ messages: recorded })
expect(replay.get()).toEqual(recorded)
expect(replay.next(recorded.slice(0, 2))).toMatchObject({
id: recorded[2].id,
role: "user",
})
})
})

View File

@@ -0,0 +1,61 @@
import type {
AnyClientTool,
UIMessage as ClientUIMessage,
ConnectConnectionAdapter,
MessagePart,
} from "@tanstack/ai-client"
import type { StreamChunk } from "@tanstack/ai/client"
import { createChatRuntime } from "../core"
import type { Chat, ChatOptions } from "../core"
import { createTanStackFormat } from "./format"
import type {
TanStackEventData,
TanStackMessageMetadata,
TanStackToolSet,
TanStackWriter,
} from "./types"
/** Options for creating a TanStack AI chat, optionally hydrated from existing messages. */
export type CreateChatOptions<
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
DATA = unknown,
> = Omit<ChatOptions, "sourceIdPrefix"> & {
messages?: Array<ClientUIMessage<TOOLS, DATA>>
}
/** The chat type returned by the TanStack AI adapter's `createChat`. */
export type TanStackChat<
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
DATA = unknown,
> = Chat<
ClientUIMessage<TOOLS, DATA>,
MessagePart<TOOLS, DATA>,
ConnectConnectionAdapter,
TanStackMessageMetadata,
TanStackEventData,
TanStackToolSet<TOOLS>,
TanStackWriter<TOOLS>
>
/** Creates a TanStack AI chat, optionally hydrated from existing messages. */
export function createChat<
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
DATA = unknown,
>(options: CreateChatOptions<TOOLS, DATA> = {}) {
const { messages, ...chatOptions } = options
return createChatRuntime<
ClientUIMessage<TOOLS, DATA>,
MessagePart<TOOLS, DATA>,
StreamChunk,
ConnectConnectionAdapter,
TanStackMessageMetadata,
TanStackEventData,
TanStackToolSet<TOOLS>,
TanStackWriter<TOOLS>
>(createTanStackFormat<TOOLS, DATA>(), {
...chatOptions,
messages,
})
}

View File

@@ -0,0 +1,230 @@
import { StreamProcessor } from "@tanstack/ai/client"
import { describe, expect, it } from "vitest"
import { createChat } from "./index"
import { getMessageText } from "./parts"
import { collectChunks, createWeatherChat, weatherOutput } from "./test-utils"
describe("TanStack transport", () => {
it("streams the golden AG-UI event sequence with runContext ids", async () => {
const chat = createWeatherChat()
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const chunks = await collectChunks(
transport.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"RUN_STARTED",
"REASONING_MESSAGE_START",
"REASONING_MESSAGE_CONTENT",
"REASONING_MESSAGE_END",
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"TOOL_CALL_RESULT",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"RUN_FINISHED",
])
expect(chunks[0]).toMatchObject({
type: "RUN_STARTED",
threadId: "thread-1",
runId: "run-1",
})
expect(chunks[4]).toMatchObject({
type: "TOOL_CALL_START",
toolCallId: "call-1",
toolCallName: "getWeather",
parentMessageId: "msg-2",
})
expect(chunks[7]).toMatchObject({
type: "TOOL_CALL_RESULT",
toolCallId: "call-1",
content: JSON.stringify(weatherOutput),
})
expect(chunks.at(-1)).toMatchObject({
type: "RUN_FINISHED",
threadId: "thread-1",
runId: "run-1",
finishReason: "stop",
})
})
it("uses chat ids when runContext is omitted", async () => {
const chat = createChat().user("Hello").assistant("Hi")
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const chunks = await collectChunks(transport.connect(initialMessages))
expect(chunks[0]).toMatchObject({
type: "RUN_STARTED",
threadId: "thread-chat",
runId: "run-chat",
})
expect(chunks.at(-1)).toMatchObject({
type: "RUN_FINISHED",
threadId: "thread-chat",
runId: "run-chat",
})
})
it("round-trips through the real TanStack StreamProcessor", async () => {
const chat = createWeatherChat()
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const processor = new StreamProcessor()
processor.setMessages(initialMessages)
processor.prepareAssistantMessage()
await processor.process(
transport.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
const messages = processor.getMessages()
const assistantMessage = messages[messages.length - 1]
expect(messages.length).toBeGreaterThanOrEqual(2)
expect(assistantMessage.role).toBe("assistant")
expect(getMessageText(assistantMessage)).toBe("It's sunny.")
const toolCallPart = assistantMessage.parts.find(
(part) => part.type === "tool-call"
)
if (!toolCallPart || toolCallPart.type !== "tool-call") {
throw new Error("Expected a tool-call part.")
}
// The installed StreamProcessor (0.40.0) keeps the accumulated
// `arguments` JSON string on the final part; it does not persist a
// parsed `input` field.
expect(toolCallPart).toMatchObject({
name: "getWeather",
state: "complete",
arguments: JSON.stringify({ city: "San Francisco" }),
output: weatherOutput,
})
})
it("streams an app-level error as RUN_ERROR", async () => {
const chat = createChat().user("Hello").error("Model unavailable.")
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const chunks = await collectChunks(
transport.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"RUN_STARTED",
"RUN_ERROR",
])
expect(chunks.at(-1)).toMatchObject({
type: "RUN_ERROR",
message: "Model unavailable.",
})
})
it("throws when no scripted assistant turn matches", () => {
const chat = createChat().user("Hello")
const transport = chat.transport()
expect(() =>
transport.connect(chat.get(), undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
).toThrowError("No assistant response found for this transcript.")
})
it("honors scripted before-start sleeps", async () => {
const chat = createChat()
.user("Hello")
.sleep(50)
.assistant(({ writer }) => {
writer.text("Hey.", {
mode: "instant",
})
})
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const startedAt = Date.now()
const iterator = transport
.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
[Symbol.asyncIterator]()
const first = await iterator.next()
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(35)
expect(first.value).toMatchObject({
type: "RUN_STARTED",
})
while (!(await iterator.next()).done) {
// Drain the remaining chunks.
}
})
it("streams hydrated assistant turns through connect", async () => {
const source = createChat().user("Hello").assistant("Hey there.")
const replay = createChat({ messages: source.get() })
const initialMessages = replay.get(1)
const chunks = await collectChunks(
replay
.transport({ delayMs: 0 })
.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
expect(chunks[0]).toMatchObject({
type: "RUN_STARTED",
threadId: "thread-1",
runId: "run-1",
})
expect(chunks.at(-1)).toMatchObject({
type: "RUN_FINISHED",
})
})
it("streams a fallback response when the script is exhausted", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [unknownMessage] = createChat()
.user("Something unscripted", { id: "unknown-1" })
.get()
const chunks = await collectChunks(
chat
.transport({ delayMs: 0, fallback: "This demo is scripted." })
.connect([...chat.get(), unknownMessage], undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
expect(chunks.map((chunk) => chunk.type)).toContain("TEXT_MESSAGE_CONTENT")
expect(
chunks
.filter((chunk) => chunk.type === "TEXT_MESSAGE_CONTENT")
.map((chunk) => ("delta" in chunk ? chunk.delta : ""))
.join("")
).toBe("This demo is scripted.")
expect(chunks.at(-1)).toMatchObject({
type: "RUN_FINISHED",
})
})
})

View File

@@ -0,0 +1,325 @@
import type {
AnyClientTool,
UIMessage as ClientUIMessage,
ConnectConnectionAdapter,
MessagePart,
RunAgentInputContext,
} from "@tanstack/ai-client"
import type { ModelMessage, StreamChunk, UIMessage } from "@tanstack/ai/client"
import { assertNever } from "../core"
import type {
ChatFormat,
ChunkEncoder,
DataTypes,
JsonRecord,
MessageRole,
ToolSet,
TransportContext,
TurnStreamOptions,
} from "../core"
import { eventsFromParts, getMessageText, materializeParts } from "./parts"
import type {
TanStackEventData,
TanStackMessageMetadata,
TanStackToolSet,
} from "./types"
/** Ids stamped onto every AG-UI event of one run. Taken from `runContext` when the client connects. */
export type TanStackRunIds = {
threadId: string
runId: string
messageId: string
}
// AG-UI event discriminants are typed as @ag-ui/core enum members
// (nominal), so plain string literals are not assignable even though the
// runtime values are identical. This is the single cast point.
function asStreamChunk(chunk: JsonRecord): StreamChunk {
return chunk as unknown as StreamChunk
}
/**
* Creates a `NeutralChunk` → AG-UI event encoder scoped to one run. Chunks
* with no AG-UI equivalent (`abort`, files, sources, `start-step`,
* `tool-output-denied`) encode to `null`; data and custom parts become
* `CUSTOM` events.
*/
export function createRunEncoder<
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
>(
runIds: TanStackRunIds
): ChunkEncoder<StreamChunk, TanStackMessageMetadata, DATA, TOOLS> {
const { threadId, runId, messageId } = runIds
return (chunk) => {
switch (chunk.type) {
case "start":
return asStreamChunk({
type: "RUN_STARTED",
threadId,
runId,
})
case "finish":
return asStreamChunk({
type: "RUN_FINISHED",
threadId,
runId,
finishReason: "stop",
})
case "error":
return asStreamChunk({
type: "RUN_ERROR",
message: chunk.errorText,
})
case "text-start":
return asStreamChunk({
type: "TEXT_MESSAGE_START",
messageId,
role: "assistant",
})
case "text-delta":
return asStreamChunk({
type: "TEXT_MESSAGE_CONTENT",
messageId,
delta: chunk.delta,
})
case "text-end":
return asStreamChunk({
type: "TEXT_MESSAGE_END",
messageId,
})
case "reasoning-start":
return asStreamChunk({
type: "REASONING_MESSAGE_START",
messageId,
role: "reasoning",
})
case "reasoning-delta":
return asStreamChunk({
type: "REASONING_MESSAGE_CONTENT",
messageId,
delta: chunk.delta,
})
case "reasoning-end":
return asStreamChunk({
type: "REASONING_MESSAGE_END",
messageId,
})
case "tool-input-available":
return [
asStreamChunk({
type: "TOOL_CALL_START",
toolCallId: chunk.toolCallId,
toolCallName: chunk.toolName,
toolName: chunk.toolName,
parentMessageId: messageId,
}),
asStreamChunk({
type: "TOOL_CALL_ARGS",
toolCallId: chunk.toolCallId,
delta: JSON.stringify(chunk.input ?? {}),
}),
asStreamChunk({
type: "TOOL_CALL_END",
toolCallId: chunk.toolCallId,
toolCallName: chunk.toolName,
input: chunk.input,
}),
]
case "tool-output-available":
return asStreamChunk({
type: "TOOL_CALL_RESULT",
messageId: `${chunk.toolCallId}-result`,
toolCallId: chunk.toolCallId,
content: JSON.stringify(chunk.output ?? {}),
role: "tool",
})
case "tool-output-error":
return asStreamChunk({
type: "TOOL_CALL_RESULT",
messageId: `${chunk.toolCallId}-result`,
toolCallId: chunk.toolCallId,
content: chunk.errorText,
role: "tool",
state: "output-error",
})
case "data":
return asStreamChunk({
type: "CUSTOM",
name: `data-${chunk.name}`,
value: {
id: chunk.id,
data: chunk.data,
transient: chunk.transient,
},
})
case "custom":
return asStreamChunk({
type: "CUSTOM",
name: chunk.part.kind,
value: chunk.part,
})
case "abort":
case "tool-approval-request":
case "tool-output-denied":
case "start-step":
case "file":
case "reasoning-file":
case "source-url":
case "source-document":
// These chunks have no AG-UI streaming equivalent.
return null
default:
return assertNever(chunk)
}
}
}
/**
* Wraps a `ReadableStream` as an `AsyncIterable` via `getReader()` (Safari
* lacks native stream iteration). Returned synchronously, as TanStack's
* connection contract requires; early `for await` exits cancel the reader.
*/
export function streamToAsyncIterable<T>(
stream: ReadableStream<T>
): AsyncIterable<T> {
return {
[Symbol.asyncIterator]() {
const reader = stream.getReader()
return {
async next(): Promise<IteratorResult<T>> {
const { done, value } = await reader.read()
if (done) {
return { done: true, value: undefined }
}
return { done: false, value }
},
async return(): Promise<IteratorResult<T>> {
await reader.cancel()
return { done: true, value: undefined }
},
}
},
}
}
/**
* The TanStack AI `ChatFormat` plugin. Its transport is a
* `ConnectConnectionAdapter` for `useChat({ connection })` and throws
* `"No assistant response found for this transcript."` when no assistant
* response remains.
*/
export function createTanStackFormat<
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
DATA = unknown,
>(): ChatFormat<
ClientUIMessage<TOOLS, DATA>,
MessagePart<TOOLS, DATA>,
StreamChunk,
ConnectConnectionAdapter,
TanStackMessageMetadata,
TanStackEventData,
TanStackToolSet<TOOLS>
> {
return {
materializeParts(events) {
return materializeParts<TOOLS, DATA>(events)
},
eventsFromParts(parts) {
return eventsFromParts<TOOLS, DATA>(parts)
},
createMessage(input) {
const message: ClientUIMessage<TOOLS, DATA> = {
id: input.id,
role: input.role,
parts: input.parts,
}
const createdAt = input.metadata.createdAt
if (typeof createdAt === "string" || createdAt instanceof Date) {
message.createdAt = new Date(createdAt)
}
return message
},
getMessageId(message) {
return message.id
},
getMessageRole(message) {
return message.role as MessageRole
},
getMessageText(message) {
return getMessageText(message)
},
getMessageParts(message) {
return message.parts
},
getMessageMetadata(message) {
return {
createdAt: message.createdAt,
}
},
createTransport(context, options = {}) {
return createConnectionAdapter<TOOLS, DATA>(context, options)
},
}
}
function createConnectionAdapter<
TOOLS extends ReadonlyArray<AnyClientTool>,
DATA,
>(
context: TransportContext<
ClientUIMessage<TOOLS, DATA>,
StreamChunk,
TanStackMessageMetadata,
TanStackEventData,
TanStackToolSet<TOOLS>
>,
options: TurnStreamOptions
): ConnectConnectionAdapter {
return {
connect(
messages: UIMessage[] | ModelMessage[],
_data?: Record<string, unknown>,
abortSignal?: AbortSignal,
runContext?: RunAgentInputContext
) {
// The chat client hands custom adapters the full UIMessage history.
const turn = context.resolveTurn(
messages as ClientUIMessage<TOOLS, DATA>[]
)
if (!turn) {
throw new Error("No assistant response found for this transcript.")
}
const encode = createRunEncoder<
TanStackEventData,
TanStackToolSet<TOOLS>
>({
threadId: runContext?.threadId ?? "thread-chat",
runId: runContext?.runId ?? "run-chat",
messageId: turn.message.id,
})
return streamToAsyncIterable(
context.streamTurn(turn, encode, options, abortSignal)
)
},
}
}

View File

@@ -0,0 +1,7 @@
export { createChat } from "./chat"
export type { CreateChatOptions, TanStackChat } from "./chat"
export type {
TanStackMessageMetadata,
TanStackToolHandle,
TanStackWriter,
} from "./types"

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest"
import { createWeatherChat, weatherOutput } from "./test-utils"
describe("TanStack parts", () => {
it("materializes writer events into TanStack message parts", () => {
const chat = createWeatherChat()
const [userMessage, assistantMessage] = chat.get()
expect(userMessage).toMatchObject({
id: "msg-1",
role: "user",
parts: [
{
type: "text",
content: "What's the weather?",
},
],
})
expect(assistantMessage.parts.map((part) => part.type)).toEqual([
"thinking",
"tool-call",
"tool-result",
"text",
])
expect(assistantMessage.parts[1]).toMatchObject({
type: "tool-call",
id: "call-1",
name: "getWeather",
state: "complete",
input: {
city: "San Francisco",
},
output: weatherOutput,
})
expect(assistantMessage.parts[2]).toMatchObject({
type: "tool-result",
toolCallId: "call-1",
state: "complete",
})
})
})

View File

@@ -0,0 +1,347 @@
import type { AnyClientTool, MessagePart, UIMessage } from "@tanstack/ai-client"
import { assertNever } from "../core"
import type { ChatEvent, FilePayload } from "../core"
import type { TanStackEventData, TanStackToolSet } from "./types"
type TextPart = Extract<MessagePart, { type: "text" }>
type ToolCallPart<TOOLS extends ReadonlyArray<AnyClientTool>> = Extract<
MessagePart<TOOLS>,
{ type: "tool-call" }
>
type ToolResultPart = Extract<MessagePart, { type: "tool-result" }>
/** Options for a static TanStack `tool-result` part. */
export type ToolResultPartOptions = {
output?: unknown
errorText?: string
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text)
} catch {
return {}
}
}
/** Concatenates the `content` of every `text` part in a message. */
export function getMessageText<
TOOLS extends ReadonlyArray<AnyClientTool>,
DATA,
>(message: Pick<UIMessage<TOOLS, DATA>, "parts">) {
return message.parts
.filter((part): part is TextPart => part.type === "text")
.map((part) => part.content)
.join("")
}
/** Converts a neutral file payload into a TanStack media part (`image`/`audio`/`video`/`document` by media type). */
export function mediaPartFromFile(file: FilePayload): MessagePart {
const source = {
type: "url" as const,
value: file.url,
mimeType: file.mediaType,
}
if (file.mediaType.startsWith("image/")) {
return {
type: "image",
source,
}
}
if (file.mediaType.startsWith("audio/")) {
return {
type: "audio",
source,
}
}
if (file.mediaType.startsWith("video/")) {
return {
type: "video",
source,
}
}
return {
type: "document",
source,
}
}
function fileFromMediaPart(part: MessagePart): FilePayload | null {
if (
part.type !== "image" &&
part.type !== "audio" &&
part.type !== "video" &&
part.type !== "document"
) {
return null
}
if (part.source.type !== "url") {
return null
}
const fallbackMediaType =
part.type === "image"
? "image/png"
: part.type === "audio"
? "audio/mpeg"
: part.type === "video"
? "video/mp4"
: "application/octet-stream"
return {
mediaType: part.source.mimeType ?? fallbackMediaType,
url: part.source.value,
}
}
export function createToolResultPart(
toolCallId: string,
options: ToolResultPartOptions = {}
): ToolResultPart {
if (options.errorText !== undefined) {
return {
type: "tool-result",
toolCallId,
content: options.errorText,
state: "error",
error: options.errorText,
}
}
return {
type: "tool-result",
toolCallId,
content: JSON.stringify(options.output ?? {}),
state: "complete",
}
}
/**
* Materializes an event log into final TanStack message parts, mirroring the
* end state the real StreamProcessor produces (tool outputs update the
* `tool-call` part and add a sibling `tool-result`). Data, source, custom,
* and step events have no TanStack part and are skipped.
*/
export function materializeParts<
TOOLS extends ReadonlyArray<AnyClientTool>,
DATA,
>(
events: ChatEvent<TanStackEventData, TanStackToolSet<TOOLS>>[]
): Array<MessagePart<TOOLS, DATA>> {
const parts: Array<MessagePart<TOOLS, DATA>> = []
function findToolCallPart(toolCallId: string) {
const index = parts.findIndex(
(part) => part.type === "tool-call" && part.id === toolCallId
)
return index === -1 ? null : (parts[index] as ToolCallPart<TOOLS>)
}
for (const event of events) {
switch (event.kind) {
case "text": {
parts.push({
type: "text",
content: event.text,
})
break
}
case "reasoning": {
parts.push({
type: "thinking",
content: event.text,
})
break
}
case "tool-input": {
parts.push({
type: "tool-call",
id: event.toolCallId,
name: event.name,
arguments: JSON.stringify(event.input ?? {}),
input: event.input ?? {},
state: "input-complete",
} as MessagePart<TOOLS, DATA>)
break
}
case "tool-output": {
const toolCallPart = findToolCallPart(event.toolCallId)
if (toolCallPart) {
toolCallPart.state = "complete"
toolCallPart.output = event.output
}
parts.push(
createToolResultPart(event.toolCallId, {
output: event.output,
}) as MessagePart<TOOLS, DATA>
)
break
}
case "tool-error": {
const toolCallPart = findToolCallPart(event.toolCallId)
if (toolCallPart) {
toolCallPart.state = "error"
}
parts.push(
createToolResultPart(event.toolCallId, {
errorText: event.errorText,
}) as MessagePart<TOOLS, DATA>
)
break
}
case "tool-denied": {
const toolCallPart = findToolCallPart(event.toolCallId)
if (toolCallPart) {
toolCallPart.approval = {
id: `${event.toolCallId}-approval`,
needsApproval: true,
approved: false,
}
}
break
}
case "file": {
parts.push(mediaPartFromFile(event.part) as MessagePart<TOOLS, DATA>)
break
}
case "sleep":
case "data":
case "error":
case "source-url":
case "source-document":
case "reasoning-file":
case "custom":
case "step-start":
case "tool-approval-request":
// These events have no TanStack message part.
break
default:
assertNever(event)
}
}
return parts
}
/** Parses TanStack message parts back into an event log so static parts can stream. */
export function eventsFromParts<
TOOLS extends ReadonlyArray<AnyClientTool>,
DATA,
>(
parts: Array<MessagePart<TOOLS, DATA>>
): ChatEvent<TanStackEventData, TanStackToolSet<TOOLS>>[] {
const events: ChatEvent<TanStackEventData, TanStackToolSet<TOOLS>>[] = []
const resolvedToolCallIds = new Set<string>()
for (const part of parts) {
// Framework-owned part unions are open, so parsing is non-exhaustive.
if (part.type === "text") {
events.push({
kind: "text",
id: `text-${events.length + 1}`,
text: part.content,
options: {
mode: "instant",
},
})
}
if (part.type === "thinking") {
events.push({
kind: "reasoning",
id: `reasoning-${events.length + 1}`,
text: part.content,
options: {
mode: "instant",
},
})
}
if (part.type === "tool-call") {
const input =
part.input !== undefined ? part.input : parseJson(part.arguments)
events.push({
kind: "tool-input",
name: part.name as keyof TanStackToolSet<TOOLS> & string,
toolCallId: part.id,
input,
})
if (part.state === "error") {
resolvedToolCallIds.add(part.id)
events.push({
kind: "tool-error",
toolCallId: part.id,
errorText: "Tool call failed.",
})
} else if (part.state === "complete" || part.output !== undefined) {
resolvedToolCallIds.add(part.id)
events.push({
kind: "tool-output",
toolCallId: part.id,
output: part.output,
})
}
}
if (part.type === "tool-result") {
if (resolvedToolCallIds.has(part.toolCallId)) {
continue
}
resolvedToolCallIds.add(part.toolCallId)
if (part.state === "error") {
events.push({
kind: "tool-error",
toolCallId: part.toolCallId,
errorText:
part.error ??
(typeof part.content === "string"
? part.content
: "Tool call failed."),
})
} else {
events.push({
kind: "tool-output",
toolCallId: part.toolCallId,
output:
typeof part.content === "string"
? parseJson(part.content)
: part.content,
})
}
}
if (
part.type === "image" ||
part.type === "audio" ||
part.type === "video" ||
part.type === "document"
) {
const file = fileFromMediaPart(part)
if (file) {
events.push({
kind: "file",
part: file,
})
}
}
}
return events
}

View File

@@ -0,0 +1,46 @@
import type { StreamChunk } from "@tanstack/ai/client"
import { createChat } from "./index"
export type WeatherOutput = {
city: string
temperature: number
condition: string
}
export const weatherOutput: WeatherOutput = {
city: "San Francisco",
temperature: 72,
condition: "sunny",
}
export async function collectChunks(iterable: AsyncIterable<StreamChunk>) {
const chunks: StreamChunk[] = []
for await (const chunk of iterable) {
chunks.push(chunk)
}
return chunks
}
export function createWeatherChat() {
return createChat()
.user("What's the weather?")
.assistant(({ writer }) => {
writer.reasoning("I need to call the weather tool.", {
mode: "instant",
})
const weather = writer.tool("getWeather", {
input: {
city: "San Francisco",
},
})
weather.output(weatherOutput)
writer.text("It's sunny.", {
mode: "instant",
})
})
}

View File

@@ -0,0 +1,54 @@
import type {
AnyClientTool,
ExtractToolInput,
ExtractToolOutput,
} from "@tanstack/ai-client"
import type { StreamTextOptions, ToolWriterOptions } from "../core"
type TanStackStreamTextOptions = Omit<StreamTextOptions, "id">
type TanStackToolWriterOptions<
TOOLS extends ReadonlyArray<AnyClientTool>,
NAME extends keyof TanStackToolSet<TOOLS>,
> = Pick<
ToolWriterOptions<TanStackToolSet<TOOLS>, NAME>,
"errorText" | "input" | "output" | "toolCallId"
>
export type TanStackMessageMetadata = {
createdAt?: Date | string
}
export type TanStackEventData = Record<never, never>
export type TanStackToolSet<TOOLS extends ReadonlyArray<AnyClientTool>> = {
[NAME in TOOLS[number]["name"]]: {
input: ExtractToolInput<TOOLS, NAME>
output: ExtractToolOutput<TOOLS, NAME>
}
}
export type TanStackToolHandle<OUTPUT> = {
sleep(delayMs: number): TanStackToolHandle<OUTPUT>
output(output: OUTPUT): TanStackToolHandle<OUTPUT>
error(errorText?: string): TanStackToolHandle<OUTPUT>
}
/** Writer operations that TanStack AI can represent end-to-end over AG-UI. */
export type TanStackWriter<TOOLS extends ReadonlyArray<AnyClientTool>> = {
text(
text?: string,
options?: TanStackStreamTextOptions
): TanStackWriter<TOOLS>
reasoning(
text?: string,
options?: TanStackStreamTextOptions
): TanStackWriter<TOOLS>
sleep(delayMs: number): TanStackWriter<TOOLS>
error(errorText?: string): TanStackWriter<TOOLS>
tool<NAME extends keyof TanStackToolSet<TOOLS> & string>(
name: NAME,
options?: TanStackToolWriterOptions<TOOLS, NAME>
): TanStackToolHandle<TanStackToolSet<TOOLS>[NAME]["output"]>
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest"
type ContractMessage = {
id: string
role: string
parts: unknown[]
}
type ContractChat<MESSAGE extends ContractMessage> = {
get(count?: number): MESSAGE[]
next(messages: readonly MESSAGE[]): MESSAGE | null
}
function mutateNestedValue(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some(mutateNestedValue)
}
if (value === null || typeof value !== "object") {
return false
}
for (const [key, child] of Object.entries(value)) {
if (typeof child === "string") {
const object = value as Record<string, unknown>
object[key] = "mutated"
return true
}
if (mutateNestedValue(child)) {
return true
}
}
return false
}
export function testChatContract<MESSAGE extends ContractMessage>(
adapter: string,
createFixture: () => ContractChat<MESSAGE>
) {
describe(`${adapter} chat contract`, () => {
it("reads the full transcript or a prefix without advancing", () => {
const chat = createFixture()
const messages = chat.get()
expect(messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"user",
"assistant",
])
expect(chat.get(2)).toEqual(messages.slice(0, 2))
expect(chat.get()).toEqual(messages)
})
it("finds the next user message from the supplied transcript", () => {
const chat = createFixture()
const messages = chat.get()
expect(chat.next(messages.slice(0, 2))?.id).toBe(messages[2].id)
expect(chat.next(messages.slice(0, 2))?.id).toBe(messages[2].id)
expect(chat.next(messages)).toBeNull()
})
it("returns detached transcript snapshots", () => {
const chat = createFixture()
const expected = chat.get()
const snapshot = chat.get()
expect(mutateNestedValue(snapshot[0].parts)).toBe(true)
expect(chat.get()).toEqual(expected)
})
})
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": false,
"baseUrl": "."
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from "tsup"
export default defineConfig((options) => ({
clean: !options.watch,
dts: true,
entry: {
"ai-sdk/index": "src/ai-sdk/index.ts",
"tanstack-ai/index": "src/tanstack-ai/index.ts",
},
format: ["esm"],
sourcemap: false,
minify: true,
target: "esnext",
outDir: "dist",
treeshake: true,
}))

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "node",
testTimeout: 8000,
},
})

2
packages/react/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
.turbo

View File

@@ -0,0 +1,29 @@
# @shadcn/react
## 0.3.1
### Patch Changes
- [#11720](https://github.com/shadcn-ui/ui/pull/11720) [`503a3a57aec9a3817e37f90aa0817b1fabd284d0`](https://github.com/shadcn-ui/ui/commit/503a3a57aec9a3817e37f90aa0817b1fabd284d0) Thanks [@shadcn](https://github.com/shadcn)! - Set `data-pending-scroll` on the MessageScroller root and viewport until `defaultScrollPosition` (`"end"` or `"last-anchor"`) is applied, so the viewport can be hidden and a server-rendered transcript does not flash the top of the thread on reload.
## 0.3.0
### Minor Changes
- [#11414](https://github.com/shadcn-ui/ui/pull/11414) [`3e54530e31020e2277df03490a30a08e3bc1792b`](https://github.com/shadcn-ui/ui/commit/3e54530e31020e2277df03490a30a08e3bc1792b) Thanks [@shadcn](https://github.com/shadcn)! - Add the Questionnaire primitive for multi-step questions, freeform answers, validation, and keyboard navigation.
## 0.2.1
### Patch Changes
- [#11106](https://github.com/shadcn-ui/ui/pull/11106) [`f6a26478547c7057aeda82516666dc3473dc39bd`](https://github.com/shadcn-ui/ui/commit/f6a26478547c7057aeda82516666dc3473dc39bd) Thanks [@shadcn](https://github.com/shadcn)! - Fix MessageScroller autoScroll around anchored turns: the anchor hold now hands off to follow-output once the streamed reply fills the viewport, content growth outrunning the frame-coalesced resize handler no longer releases follow-output, and the scroll button no longer flickers while follow-output closes the gap a streamed chunk just opened.
- [#11100](https://github.com/shadcn-ui/ui/pull/11100) [`dd6ce77cf1606eee2839368a97cc35417f763232`](https://github.com/shadcn-ui/ui/commit/dd6ce77cf1606eee2839368a97cc35417f763232) Thanks [@shadcn](https://github.com/shadcn)! - Fix MessageScroller abandoning a newly appended scrollAnchor placement when autoScroll is enabled, so the anchored turn holds at the reading line while the reply streams.
- [#11085](https://github.com/shadcn-ui/ui/pull/11085) [`628e427231ca4bb7f586a69f99fe7809749a53e2`](https://github.com/shadcn-ui/ui/commit/628e427231ca4bb7f586a69f99fe7809749a53e2) Thanks [@devnomic](https://github.com/devnomic)! - Fix MessageScroller firing "ResizeObserver loop completed with undelivered notifications" during streamed content growth by coalescing ResizeObserver callbacks into requestAnimationFrame.
## 0.2.0
### Minor Changes
- [#11022](https://github.com/shadcn-ui/ui/pull/11022) [`18fcf0f766857a7249cc0daac3c1609610edd158`](https://github.com/shadcn-ui/ui/commit/18fcf0f766857a7249cc0daac3c1609610edd158) Thanks [@shadcn](https://github.com/shadcn)! - initial release of @shadcn/react

21
packages/react/README.md Normal file
View File

@@ -0,0 +1,21 @@
# @shadcn/react
Unstyled components for React.
## Installation
```bash
npm install @shadcn/react
```
## Documentation
Visit https://ui.shadcn.com/docs to view the documentation.
## Contributing
Please read the [contributing guide](https://github.com/shadcn-ui/ui/blob/main/CONTRIBUTING.md).
## License
Licensed under the [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).

View File

@@ -0,0 +1,79 @@
{
"name": "@shadcn/react",
"version": "0.3.1",
"description": "Unstyled components for React.",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"author": {
"name": "shadcn",
"url": "https://twitter.com/shadcn"
},
"repository": {
"type": "git",
"url": "https://github.com/shadcn-ui/ui.git",
"directory": "packages/react"
},
"homepage": "https://ui.shadcn.com",
"bugs": {
"url": "https://github.com/shadcn-ui/ui/issues"
},
"files": [
"dist"
],
"keywords": [
"react",
"headless",
"unstyled",
"primitives",
"accessible",
"ui",
"shadcn"
],
"type": "module",
"sideEffects": false,
"exports": {
"./message-scroller": {
"types": "./dist/message-scroller/index.d.ts",
"default": "./dist/message-scroller/index.js"
},
"./questionnaire": {
"types": "./dist/questionnaire/index.d.ts",
"default": "./dist/questionnaire/index.js"
}
},
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist",
"test": "vitest run",
"test:browser": "vitest run -c vitest.browser.config.ts"
},
"peerDependencies": {
"@types/react": ">=19",
"react": ">=19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
},
"devDependencies": {
"@types/react-dom": "19.2.2",
"@vitest/browser": "^3.2.6",
"jsdom": "^28.1.0",
"playwright": "^1.61.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"rimraf": "^6.0.1",
"tsup": "^8.5.0",
"typescript": "^5.9.2",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^3.2.6"
}
}

View File

@@ -0,0 +1,176 @@
# MessageScroller — performance
MessageScroller is **not virtualized** by default. It renders every message as a real DOM
node and tracks scroll position with geometry reads on the scroll, resize, and
content-change paths. The question this doc answers — with a runnable benchmark,
not assertions of faith — is whether that design holds up for long, markdown-rich
transcripts.
**Verdict: yes, with wide headroom.** A scroll commit on 1000 messages blocks the
main thread for under 1ms; the same on 600 heavy-markdown items (64k DOM nodes)
is well under 1ms with zero dropped frames; and while a heavy reply streams in,
the scroller's own per-token work is ~1–2ms — a fraction of a frame on top of the
markdown render. The limiting factor at extreme scale is the un-virtualized row
count itself, not the scroller's work.
## Running it
```bash
# from packages/react
pnpm test:browser src/message-scroller/message-scroller.perf.browser.test.tsx
```
It runs in real Chromium via Playwright (`vitest.browser.config.ts`) and prints a
numbers table per layer. **It must run in a real browser** — the entire cost here
is forced layout, and jsdom stubs `getBoundingClientRect` to a free property
read, so a jsdom "benchmark" would measure nothing real. The file matches the
`*.browser.test.tsx` glob, so it also runs in CI (`browser-tests.yml`) and
doubles as a regression guard.
**The suite runs outside React's act environment.** Both browser test files set
`IS_REACT_ACT_ENVIRONMENT = false`. This component is driven by the event loop —
`requestAnimationFrame`, `ResizeObserver`, `IntersectionObserver` — which `act()`
cannot wrap, so the tests flush the renders they time with `flushSync` and settle
async scroll work on real frames. Do not reintroduce `act()` here: it re-adds the
spurious "update not wrapped in act(...)" warnings from the rAF-driven visibility
subscriber without changing what is measured.
## Why this is the right thing to measure
The scroller's hot-path cost is not the `getBoundingClientRect` _call_ — it is the
**forced synchronous reflow** that call triggers when layout is dirty. The
benchmark is built around two facts about when layout is and isn't dirty:
- **Scrolling does not dirty layout.** Changing `scrollTop` is a scroll-offset
change, not a layout invalidation, so during steady scroll `getBoundingClientRect`
reads already-computed boxes. Cost tracks the _number of items iterated_ (one
rect read per top-level row in `getContentBottom`), and is nearly blind to how
deep each row's subtree is. This is why heavy markdown barely changes the scroll
numbers.
- **Growth dirties layout.** When a streaming reply gets taller, the next geometry
read pays for a real reflow. That is the only path where DOM weight can bite, so
it gets its own layer and an A/B isolation against a plain scroll div.
## The three layers
### Layer 1 — algorithmic scaling (isolated)
Calls `getMessageScrollerScrollable` (which drives the O(n) `getContentBottom` scan) in
a tight loop against a real laid-out DOM, toggling `scrollTop` each iteration to
force the reflow that real scrolling forces. Answers: how does one scroll commit
scale with row count?
### Layer 2 — real scroll under load (the integration proof)
Mounts a large transcript with scroll-state and visibility both subscribed (a real
app), drives a top-to-bottom scroll, and times the **synchronous scroll handler**
(`handleScroll → syncAfterScroll → commitScrollState → getContentBottom` runs
inside `dispatchEvent`, so timing the dispatch is timing the exact main-thread
block a user scroll causes). Counts long tasks. Run both with trivial rows and
with heavy markdown rows.
### Layer 3 — streaming (the real-chat worst case)
Streams an assistant reply token-by-token at the bottom of a long transcript of
**memoized** heavy turns (so only the growing reply re-renders, like a real app).
Runs the identical growth inside MessageScroller (auto-follow) and inside a plain
scroll `<div>` (manual follow); the **delta** isolates the scroller's per-token
cost from the markdown's own render cost.
## Indicative numbers
> Measured on Apple Silicon, headless Chromium (Playwright), 2026-06. These are
> **indicative, not guarantees** — absolute timings drift with hardware and CI.
> The durable contract is the budget assertions below; the test is the source of
> truth. Re-run to get numbers for your machine.
**Layer 1 — per-commit cost vs. row count (trivial rows):**
| messages | median | p95 | max |
| -------- | ------ | ----- | ----- |
| 100 | 0.1ms | 0.1ms | 0.7ms |
| 500 | 0.3ms | 0.3ms | 1.2ms |
| 1000 | 0.5ms | 0.6ms | 1.9ms |
| 2000 | 1.1ms | 1.2ms | 4.6ms |
Linear in row count with a tiny constant. Extrapolated, a single commit alone
would not reach one 16ms frame until ~30,000 rendered rows.
**Layer 1 — per-commit cost vs. DOM weight (heavy markdown):**
| turns | items | DOM nodes | median | p95 |
| ----- | ----- | --------- | ------ | ----- |
| 50 | 100 | 10,509 | 0.1ms | 0.2ms |
| 150 | 300 | 31,996 | 0.2ms | 0.3ms |
| 300 | 600 | 64,135 | 0.4ms | 0.5ms |
64k DOM nodes barely moves the cost — it tracks item count, not subtree depth,
because scrolling doesn't dirty layout (see above).
**Layer 2 — real top-to-bottom scroll:**
| scenario | handler median | handler p95 | handler max | long tasks |
| --------------------------- | -------------- | ----------- | ----------- | ---------- |
| 1000 trivial messages | 0.9ms | 1.2ms | 2.1ms | 0 |
| 300 heavy turns (64k nodes) | 0.6ms | 0.8ms | 0.9ms | 0 |
**Layer 3 — streaming a heavy reply (per-token commit, `flushSync`-timed):**
| tree | median | p95 | max | long tasks |
| ---------------------- | ---------- | ----- | ----- | ---------- |
| plain `<div>` baseline | 1.2ms | 2.2ms | 2.3ms | 0 |
| MessageScroller | 3.2ms | 5.7ms | 8.3ms | 0 |
| **scroller overhead** | **~1–2ms** | | | |
Each token re-renders the growing reply; `flushSync` times that commit plus the
scroller's `handleContentChange` layout effect (re-scan items, follow-bottom).
The overhead over a plain `<div>` is the scroller's synchronous content-change
work — a consistent **~1–2ms per token**, a fraction of a frame, with zero long
tasks. (An earlier `act()`-wrapped version of this layer timed the whole async
flush and spuriously reported ~0/negative overhead; the `flushSync` timing is the
honest one.) The per-token total is still dominated by the markdown render, which
is the message component's cost, not the scroller's.
## Preventing regressions
The suite runs in CI on every PR that touches `packages/react/**`
(`.github/workflows/browser-tests.yml`). It guards against regressions two
ways, in order of strength:
### 1. Deterministic guard (the real one)
`getBoundingClientRect` is spied during a single scroll commit and the call count
is asserted to be **O(items), not O(items²)** — currently ~1.0 reads per item
(2030 reads for 2000 items). This count is **machine-independent and has zero
variance**, so it can be a tight bound: the classic perf regression (a nested rect
loop, an accidental per-item `getComputedStyle`) makes the per-item ratio scale
with item count and blows past the bound deterministically, where a timing test
would just shrug. This is the assertion to trust.
### 2. Timing budgets (coarse backstop)
Wall-clock assertions catch a catastrophic slowdown, but they are
hardware-dependent, so they carry generous slack and only flag multi-× blowups:
- Layer 1: per-commit median at the largest row count `< 16ms` (one frame).
- Layer 2: scroll-handler median `< 16ms`, p95 `< 32ms`.
- Layer 3: scroller per-token median `< 16ms`, and overhead over baseline `< 16ms`.
Do not tighten the timing budgets toward the indicative numbers — CI runner noise
will make them flake. If you want an earlier warning on gradual creep, prefer
adding more invariants to the deterministic guard (more counts, scaling-ratio
checks between two row counts measured in the same run) over shrinking the ms
thresholds.
## Boundaries and non-goals
- **No virtualization.** Cost is O(rendered rows). Realistic transcripts (hundreds
to low thousands of turns) have enormous headroom; tens of thousands of _live_
rows would eventually make `getContentBottom`'s all-items scan matter. The fix is
already scoped if ever needed: the last non-spacer child carries the max bottom,
so the scan can collapse to O(1).
- **Markdown render cost is out of scope.** Layer 3 shows per-token cost is the
content renderer's, not the scroller's. Mitigations (memoizing history, batching
tokens, virtualizing code blocks) belong to the message components.
- **Timings are machine-relative.** Don't copy these numbers into user-facing docs
as promises; cite the methodology and re-run.

View File

@@ -0,0 +1,73 @@
# MessageScroller
Headless scroll container for chat transcripts. Owns scroll position, anchoring,
auto-follow, and visibility tracking so message components can stay presentational.
## Usage
```tsx
import {
MessageScroller,
useMessageScroller,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
;<MessageScroller.Provider autoScroll>
<MessageScroller.Root>
<MessageScroller.Viewport>
<MessageScroller.Content>
<MessageScroller.Item messageId="m1" scrollAnchor>
…
</MessageScroller.Item>
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller.Root>
</MessageScroller.Provider>
```
## Exports
All from `@shadcn/react/message-scroller`.
### Parts
| Part | Role | Notable props |
| -------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `MessageScroller.Provider` | Headless root; owns scroll state, anchoring, auto-follow, and visibility | `autoScroll`, `defaultScrollPosition`, `scrollPreviousItemPeek`, `scrollMargin`, `scrollEdgeThreshold` |
| `MessageScroller.Root` | Styled frame; lays out the viewport, content, and controls in the provider | `data-pending-scroll` until the opening position applies |
| `MessageScroller.Viewport` | Scrollable frame | `preserveScrollOnPrepend`; `data-pending-scroll` until the opening position applies |
| `MessageScroller.Content` | Message list; defaults `role="log"` + `aria-relevant="additions"` | — |
| `MessageScroller.Item` | One message wrapper | `messageId`, `scrollAnchor` |
| `MessageScroller.Button` | Scroll-to-end/start affordance; auto-hides when caught up | `direction` |
### Hooks (flat siblings)
| Hook | Returns |
| -------------------------------- | --------------------------------------------------------------------------- |
| `useMessageScroller()` | `{ scrollToMessage, scrollToStart, scrollToEnd }` |
| `useMessageScrollerScrollable()` | `MessageScrollerScrollable` — `{ start, end }`, the edges the viewport can scroll toward |
| `useMessageScrollerVisibility()` | `MessageScrollerVisibilityState` — `currentAnchorId`, `visibleMessageIds` |
### Types
| Type | Meaning |
| -------------------------------------- | --------------------------------------------------------------------- |
| `MessageScrollerScrollable` | result of `useMessageScrollerScrollable()` |
| `MessageScrollerVisibilityState` | result of `useMessageScrollerVisibility()` |
| `MessageScrollerScrollOptions` | options for the scroll commands (`align`, `behavior`, `scrollMargin`) |
| `MessageScrollerScrollAlign` | `"start" \| "center" \| "end" \| "nearest"` |
| `MessageScrollerDefaultScrollPosition` | `"start" \| "end" \| "last-anchor"` |
## Tests
| File | Environment | Covers |
| ---------------------------------------- | ----------- | -------------------------------------------------------------------------- |
| `geometry.test.ts` | jsdom | Geometry math with stubbed rects. |
| `message-scroller.browser.test.tsx` | chromium | Behavior jsdom can't model (native scroll anchoring, prepend, visibility). |
| `message-scroller.perf.browser.test.tsx` | chromium | Performance benchmark + regression guard. |
```bash
pnpm test # unit (jsdom)
pnpm test:browser # behavior + performance (chromium)
```

View File

@@ -0,0 +1,446 @@
import * as React from "react"
import { composeRefs, mergeProps, useRender } from "../use-render"
import { USER_SCROLL_KEYS } from "./types"
import type {
MessageScrollerButtonProps,
MessageScrollerContentProps,
MessageScrollerContextValue,
MessageScrollerItemProps,
MessageScrollerProps,
MessageScrollerProviderProps,
MessageScrollerRegisterMessage,
MessageScrollerViewportProps,
} from "./types"
import { useMessageScrollerController } from "./use-message-scroller-controller"
import { useLatest } from "./utils"
const MessageScrollerContext =
React.createContext<MessageScrollerContextValue | null>(null)
const MessageScrollerItemContext =
React.createContext<MessageScrollerRegisterMessage | null>(null)
function useMessageScrollerContext() {
const context = React.useContext(MessageScrollerContext)
if (!context) {
throw new Error("useMessageScroller must be used within a MessageScroller.")
}
return context
}
function useMessageScrollerItemContext() {
const context = React.useContext(MessageScrollerItemContext)
if (!context) {
throw new Error(
"MessageScrollerItem must be used within a MessageScroller."
)
}
return context
}
function useMessageScroller() {
const { scrollToEnd, scrollToMessage, scrollToStart } =
useMessageScrollerContext()
return React.useMemo(
() => ({
scrollToEnd,
scrollToMessage,
scrollToStart,
}),
[scrollToEnd, scrollToMessage, scrollToStart]
)
}
function useMessageScrollerScrollable() {
const { stateStore } = useMessageScrollerContext()
return React.useSyncExternalStore(
stateStore.subscribe,
stateStore.getSnapshot,
stateStore.getSnapshot
)
}
function useMessageScrollerVisibility() {
const { observeVisibility, unobserveVisibility, visibilityStore } =
useMessageScrollerContext()
const subscribe = React.useCallback(
(listener: () => void) =>
visibilityStore.subscribe(
listener,
observeVisibility,
unobserveVisibility
),
[observeVisibility, unobserveVisibility, visibilityStore]
)
return React.useSyncExternalStore(
subscribe,
visibilityStore.getSnapshot,
visibilityStore.getSnapshot
)
}
function MessageScrollerProvider({
autoScroll = false,
children,
defaultScrollPosition = "end",
scrollEdgeThreshold,
scrollPreviousItemPeek,
scrollMargin,
}: MessageScrollerProviderProps) {
const { context, registerMessage } = useMessageScrollerController({
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollPreviousItemPeek,
scrollMargin,
})
return (
<MessageScrollerContext.Provider value={context}>
<MessageScrollerItemContext.Provider value={registerMessage}>
{children}
</MessageScrollerItemContext.Provider>
</MessageScrollerContext.Provider>
)
}
function usePendingDefaultScroll() {
const { pendingDefaultScrollStore } = useMessageScrollerContext()
return React.useSyncExternalStore(
pendingDefaultScrollStore.subscribe,
pendingDefaultScrollStore.getSnapshot,
pendingDefaultScrollStore.getSnapshot
)
}
function MessageScroller({ children, ...props }: MessageScrollerProps) {
const { setRootElement } = useMessageScrollerContext()
const pendingDefaultScroll = usePendingDefaultScroll()
return (
<div
ref={setRootElement}
{...props}
{...(pendingDefaultScroll ? { "data-pending-scroll": "" } : null)}
>
{children}
</div>
)
}
function MessageScrollerViewport({
"aria-label": ariaLabel,
children,
onKeyDown,
onScroll,
onTouchMove,
onWheel,
preserveScrollOnPrepend = true,
ref,
role,
tabIndex,
...props
}: MessageScrollerViewportProps) {
const {
handleResize,
preserveScrollOnPrependRef,
setViewportElement,
syncAfterScroll,
userScrollIntent,
viewportRef,
} = useMessageScrollerContext()
const pendingDefaultScroll = usePendingDefaultScroll()
preserveScrollOnPrependRef.current = preserveScrollOnPrepend
const setViewportRef = React.useCallback(
(element: HTMLDivElement | null) => {
setViewportElement(element)
composeRefs(ref)?.(element)
},
[ref, setViewportElement]
)
function handleScroll(event: React.UIEvent<HTMLDivElement>) {
syncAfterScroll()
onScroll?.(event)
}
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
userScrollIntent()
onWheel?.(event)
}
function handleTouchMove(event: React.TouchEvent<HTMLDivElement>) {
userScrollIntent()
onTouchMove?.(event)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (USER_SCROLL_KEYS.has(event.key)) {
userScrollIntent()
}
onKeyDown?.(event)
}
React.useEffect(() => {
const viewport = viewportRef.current
if (!viewport || typeof ResizeObserver === "undefined") {
return
}
// Coalesce into rAF: handleResize mutates the spacer inside the observed
// content, and resizing an observed element during delivery fires
// "ResizeObserver loop completed with undelivered notifications".
let frame = 0
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(handleResize)
})
observer.observe(viewport)
return () => {
window.cancelAnimationFrame(frame)
observer.disconnect()
}
}, [handleResize, viewportRef])
return (
<div
ref={setViewportRef}
role={role ?? "region"}
aria-label={ariaLabel ?? "Messages"}
tabIndex={tabIndex ?? 0}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
onTouchMove={handleTouchMove}
onWheel={handleWheel}
{...props}
{...(pendingDefaultScroll ? { "data-pending-scroll": "" } : null)}
>
{children}
</div>
)
}
function MessageScrollerContent({
"aria-relevant": ariaRelevant,
children,
ref,
role,
spacerClassName,
...props
}: MessageScrollerContentProps) {
const {
handleContentChange,
handleResize,
setContentElement,
setSpacerElement,
} = useMessageScrollerContext()
const contentRef = React.useRef<HTMLDivElement | null>(null)
const setContentRef = React.useCallback(
(element: HTMLDivElement | null) => {
contentRef.current = element
setContentElement(element)
composeRefs(ref)?.(element)
},
[ref, setContentElement]
)
React.useLayoutEffect(() => {
const content = contentRef.current
if (!content) {
return
}
handleContentChange()
if (typeof MutationObserver === "undefined") {
return
}
const observer = new MutationObserver(() => {
handleContentChange()
})
observer.observe(content, { childList: true })
return () => observer.disconnect()
}, [handleContentChange])
React.useEffect(() => {
const content = contentRef.current
if (!content || typeof ResizeObserver === "undefined") {
return
}
// Coalesce into rAF: handleResize mutates the spacer inside this observed
// element, and resizing an observed element during delivery fires
// "ResizeObserver loop completed with undelivered notifications".
let frame = 0
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(handleResize)
})
observer.observe(content)
return () => {
window.cancelAnimationFrame(frame)
observer.disconnect()
}
}, [handleResize])
return (
<div
ref={setContentRef}
role={role ?? "log"}
aria-relevant={ariaRelevant ?? "additions"}
{...props}
>
{children}
<div
ref={setSpacerElement}
aria-hidden="true"
data-message-scroller-spacer=""
hidden
className={spacerClassName}
/>
</div>
)
}
function MessageScrollerItem({
messageId,
ref,
scrollAnchor = false,
...props
}: MessageScrollerItemProps) {
const registerMessage = useMessageScrollerItemContext()
const elementRef = React.useRef<HTMLDivElement | null>(null)
const setItemRef = React.useCallback(
(element: HTMLDivElement | null) => {
const previousElement = elementRef.current
elementRef.current = element
if (messageId) {
registerMessage(messageId, element, previousElement)
}
composeRefs(ref)?.(element)
},
[messageId, ref, registerMessage]
)
return (
<div
ref={setItemRef}
data-message-id={messageId}
data-scroll-anchor={scrollAnchor ? "true" : "false"}
{...props}
/>
)
}
function MessageScrollerButton({
behavior = "smooth",
children,
direction = "end",
onClick,
render,
tabIndex,
type = "button",
...props
}: MessageScrollerButtonProps) {
const { scrollToEnd, scrollToStart, stateStore } = useMessageScrollerContext()
const onClickRef = useLatest(onClick)
const subscribe = React.useCallback(
(listener: () => void) => stateStore.subscribe(listener),
[stateStore]
)
const getSnapshot = React.useCallback(() => {
const state = stateStore.getSnapshot()
return direction === "start" ? state.start : state.end
}, [direction, stateStore])
const isActive = React.useSyncExternalStore(
subscribe,
getSnapshot,
getSnapshot
)
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (!isActive) {
return
}
onClickRef.current?.(event)
if (!event.defaultPrevented) {
event.currentTarget.blur()
if (direction === "start") {
scrollToStart({ behavior })
} else {
scrollToEnd({ behavior })
}
}
},
[behavior, direction, isActive, onClickRef, scrollToEnd, scrollToStart]
)
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type,
inert: !isActive,
tabIndex: isActive ? tabIndex : -1,
children: children ?? <span>Scroll to {direction}</span>,
onClick: handleClick,
},
props
),
render,
state: {
active: isActive,
direction,
},
stateAttributesMapping: {
active: (value) => ({
"data-active": value ? "true" : "false",
}),
},
})
}
export {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}

View File

@@ -0,0 +1,451 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest"
import {
getContentBottom,
getElementScrollTop,
getLastScrollAnchor,
getMessageScrollerScrollable,
getNewScrollAnchor,
getUnanchoredScrollAnchor,
} from "./geometry"
// jsdom does not compute layout, so each element's rect, scroll position, and
// dimensions are stubbed directly. Rects are expressed in viewport (client)
// coordinates and shift with the owning viewport's scrollTop.
type RectInput = {
top: number
height: number
}
const cleanups: Array<() => void> = []
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.()
}
})
// Override window.getComputedStyle once per test so padding stubs read from
// data attributes set on the element.
function stubComputedStyle() {
const original = window.getComputedStyle
window.getComputedStyle = ((element: Element) => {
const style = original(element)
if (element instanceof HTMLElement && element.dataset.paddingStart) {
return {
...style,
gap: "0px",
rowGap: "0px",
paddingBlockStart: `${element.dataset.paddingStart}px`,
paddingTop: `${element.dataset.paddingStart}px`,
paddingBlockEnd: `${element.dataset.paddingEnd ?? "0"}px`,
paddingBottom: `${element.dataset.paddingEnd ?? "0"}px`,
} as CSSStyleDeclaration
}
return style
}) as typeof window.getComputedStyle
cleanups.push(() => {
window.getComputedStyle = original
})
}
function setRect(element: HTMLElement, rect: RectInput) {
element.getBoundingClientRect = () =>
({
top: rect.top,
bottom: rect.top + rect.height,
height: rect.height,
left: 0,
right: 0,
width: 0,
x: 0,
y: rect.top,
toJSON: () => ({}),
}) as DOMRect
}
function setClientHeight(element: HTMLElement, height: number) {
Object.defineProperty(element, "clientHeight", {
configurable: true,
get: () => height,
})
}
// Build a viewport/content/items fixture. The viewport rect is anchored at
// `viewportTop` in client space; item rects are given in viewport-relative
// terms and offset by viewportTop so getElementTop math lines up with scrollTop.
function createFixture(options: {
viewportTop?: number
viewportHeight: number
scrollTop?: number
scrollHeight?: number
contentPaddingStart?: number
contentPaddingEnd?: number
items: Array<{
messageId?: string
scrollAnchor?: boolean
top: number
height: number
}>
}) {
const viewportTop = options.viewportTop ?? 0
const scrollTop = options.scrollTop ?? 0
const viewport = document.createElement("div")
const content = document.createElement("div")
const spacer = document.createElement("div")
viewport.appendChild(content)
content.appendChild(spacer)
setRect(viewport, { top: viewportTop, height: options.viewportHeight })
setClientHeight(viewport, options.viewportHeight)
viewport.scrollTop = scrollTop
if (typeof options.scrollHeight === "number") {
Object.defineProperty(viewport, "scrollHeight", {
configurable: true,
get: () => options.scrollHeight as number,
})
}
if (typeof options.contentPaddingStart === "number") {
content.dataset.paddingStart = String(options.contentPaddingStart)
content.dataset.paddingEnd = String(options.contentPaddingEnd ?? 0)
}
const items = options.items.map((item) => {
const element = document.createElement("div")
if (item.messageId) {
element.dataset.messageId = item.messageId
}
element.dataset.scrollAnchor = item.scrollAnchor ? "true" : "false"
// Item rects are in client coordinates: viewport-relative top shifted by the
// viewport's own top so getElementTop adds back scrollTop correctly.
setRect(element, { top: viewportTop + item.top, height: item.height })
content.insertBefore(element, spacer)
return element
})
return { viewport, content, spacer, items }
}
function createItems(
specs: Array<{ id: string; anchor?: boolean }>
): HTMLElement[] {
return specs.map((spec) => {
const element = document.createElement("div")
element.dataset.messageId = spec.id
element.dataset.scrollAnchor = spec.anchor ? "true" : "false"
return element
})
}
describe("getElementScrollTop", () => {
it("aligns to start by subtracting content padding and margin", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 10,
contentPaddingEnd: 10,
items: [{ messageId: "a", top: 100, height: 50 }],
})
// elementTop = 100; minus padding.start (10) minus scrollMargin (5).
expect(
getElementScrollTop({
align: "start",
element: items[0],
scrollMargin: 5,
spacer,
viewport,
})
).toBe(85)
})
it("centers the element within the padded inset", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 20,
contentPaddingEnd: 20,
items: [{ messageId: "a", top: 300, height: 40 }],
})
// insetHeight = 200 - 20 - 20 = 160; offset = (160 - 40) / 2 = 60.
// elementTop (300) - padding.start (20) - 60 - scrollMargin (0) = 220.
expect(
getElementScrollTop({
align: "center",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(220)
})
it("aligns to end against the viewport bottom", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 0,
contentPaddingEnd: 8,
items: [{ messageId: "a", top: 300, height: 50 }],
})
// elementTop (300) - clientHeight (200) + height (50) + padding.end (8)
// + scrollMargin (0) = 158.
expect(
getElementScrollTop({
align: "end",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(158)
})
it("returns the current scrollTop when nearest and already visible", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
scrollTop: 50,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// Element top relative to viewport is 60, so in content space it sits at
// scrollTop (50) + 60 = 110, fully inside [50, 250].
items: [{ messageId: "a", top: 60, height: 40 }],
})
expect(
getElementScrollTop({
align: "nearest",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(50)
})
it("scrolls up to reveal an element above the viewport when nearest", () => {
stubComputedStyle()
const { items, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
scrollTop: 300,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// Relative top -100 means content-space top is 300 + (-100) = 200, which
// is above viewportTop (scrollTop 300), so it must scroll up to 200.
items: [{ messageId: "a", top: -100, height: 40 }],
})
expect(
getElementScrollTop({
align: "nearest",
element: items[0],
scrollMargin: 0,
spacer,
viewport,
})
).toBe(200)
})
})
describe("getContentBottom", () => {
it("returns padding sum when there are no items", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 12,
contentPaddingEnd: 8,
items: [],
})
expect(getContentBottom({ content, spacer, viewport })).toBe(20)
})
it("measures from the lowest item bottom plus end padding", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
scrollTop: 30,
contentPaddingStart: 0,
contentPaddingEnd: 16,
items: [
{ messageId: "a", top: 0, height: 100 },
{ messageId: "b", top: 100, height: 150 },
],
})
// Lowest item bottom in client space is 250; minus viewportRect.top (0)
// plus scrollTop (30) plus padding.end (16) = 296.
expect(getContentBottom({ content, spacer, viewport })).toBe(296)
})
it("excludes the spacer from the measurement", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
scrollTop: 0,
contentPaddingStart: 0,
contentPaddingEnd: 0,
items: [{ messageId: "a", top: 0, height: 80 }],
})
// Give the spacer a tall rect; it must not raise contentBottom past 80.
setRect(spacer, { top: 0, height: 9999 })
expect(getContentBottom({ content, spacer, viewport })).toBe(80)
})
})
describe("getNewScrollAnchor / getLastScrollAnchor", () => {
it("finds the first anchor at or after the previous item count", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b" },
{ id: "c", anchor: true },
{ id: "d", anchor: true },
])
// Starting from index 1 skips the anchor at index 0.
expect(getNewScrollAnchor(items, 1)).toBe(items[2])
})
it("returns null when no anchor exists after the boundary", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b" },
{ id: "c" },
])
expect(getNewScrollAnchor(items, 1)).toBeNull()
})
it("finds the last anchor scanning from the end", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b", anchor: true },
{ id: "c" },
])
expect(getLastScrollAnchor(items)).toBe(items[1])
})
it("returns null when there are no anchors", () => {
const items = createItems([{ id: "a" }, { id: "b" }])
expect(getLastScrollAnchor(items)).toBeNull()
})
})
describe("getUnanchoredScrollAnchor", () => {
it("returns the first anchor that has not been handled yet", () => {
const items = createItems([
{ id: "a", anchor: true },
{ id: "b", anchor: true },
])
const handled = new WeakSet<HTMLElement>()
expect(getUnanchoredScrollAnchor(items, handled)).toBe(items[0])
handled.add(items[0]!)
expect(getUnanchoredScrollAnchor(items, handled)).toBe(items[1])
})
it("returns null when every anchor has already been handled", () => {
const items = createItems([{ id: "a", anchor: true }])
const handled = new WeakSet<HTMLElement>([items[0]!])
expect(getUnanchoredScrollAnchor(items, handled)).toBeNull()
})
})
describe("getMessageScrollerScrollable", () => {
it("cannot scroll toward either edge when viewport or content is missing", () => {
const scrollable = getMessageScrollerScrollable({
content: null,
scrollEdgeThreshold: 8,
spacer: null,
viewport: null,
})
expect(scrollable).toMatchObject({
start: false,
end: false,
})
})
it("treats sub-threshold gaps at both edges as not scrollable", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
// scrollTop 5 <= threshold 8, so it cannot scroll toward the start.
scrollTop: 5,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// contentBottom 204; 204 - 5 - 200 = -1 <= 8, so it cannot scroll toward the end.
items: [{ messageId: "a", top: 199, height: 0 }],
})
const scrollable = getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
expect(scrollable).toMatchObject({
start: false,
end: false,
})
})
it("can scroll toward both edges past the threshold", () => {
stubComputedStyle()
const { content, spacer, viewport } = createFixture({
viewportHeight: 200,
viewportTop: 0,
// scrollTop 100 > threshold 8, so it can scroll toward the start.
scrollTop: 100,
contentPaddingStart: 0,
contentPaddingEnd: 0,
// contentBottom 1000; 1000 - 100 - 200 = 700 > 8, so it can scroll toward the end.
items: [{ messageId: "a", top: 900, height: 100 }],
})
const scrollable = getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
expect(scrollable).toMatchObject({
start: true,
end: true,
})
})
})

View File

@@ -0,0 +1,387 @@
import {
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
SCROLL_POSITION_EPSILON,
} from "./types"
import type {
MessageScrollerScrollable,
MessageScrollerScrollAlign,
MessageScrollerVisibilityState,
} from "./types"
function getMessageScrollerScrollable({
content,
scrollEdgeThreshold,
spacer,
viewport,
}: {
content: HTMLElement | null
scrollEdgeThreshold: number
spacer: HTMLElement | null
viewport: HTMLElement | null
}): MessageScrollerScrollable {
if (!viewport || !content) {
return EMPTY_MESSAGE_SCROLLER_SCROLLABLE
}
const contentBottom = getContentBottom({ content, spacer, viewport })
return {
start: viewport.scrollTop > scrollEdgeThreshold,
end:
contentBottom - viewport.scrollTop - viewport.clientHeight >
scrollEdgeThreshold,
}
}
function getMessageScrollerVisibilityState({
content,
scrollMargin,
scrollPreviousItemPeek,
spacer,
viewport,
visibleMessageIds,
}: {
content: HTMLElement | null
scrollMargin: number
scrollPreviousItemPeek: number
spacer: HTMLElement | null
viewport: HTMLElement | null
visibleMessageIds: Set<string>
}): MessageScrollerVisibilityState {
if (!content || !viewport) {
return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
}
const viewportRect = viewport.getBoundingClientRect()
// The reading line sits scrollPreviousItemPeek below scrollMargin: anchored
// turns land there with the previous turn peeking above. A row only peeking in
// that band has not been read down to yet, so it counts as neither visible nor
// current.
const lineTop = viewportRect.top + scrollMargin + scrollPreviousItemPeek
const trackByLayout = typeof IntersectionObserver === "undefined"
const visible: string[] = []
let currentAnchorId: string | null = null
// Walk rows in document order so visible ids come out top-to-bottom.
for (const item of getMessageScrollerItems(content, spacer)) {
const messageId = item.dataset.messageId
if (!messageId) {
continue
}
const isAnchor = item.dataset.scrollAnchor === "true"
// Anchors need a rect to place the current line; non-anchors lean on the
// observer set (or a rect in the no-observer fallback).
const rect = isAnchor || trackByLayout ? item.getBoundingClientRect() : null
const isVisible =
trackByLayout && rect
? rect.bottom > lineTop && rect.top < viewportRect.bottom
: visibleMessageIds.has(messageId)
if (isVisible) {
visible.push(messageId)
}
// Current is the last anchor to have reached the reading line: the turn you
// scrolled to (placed at the line) wins over newer turns lower down, the
// previous turn peeking above the line has been passed, and it stays current
// even after its header scrolls above the viewport.
if (isAnchor && rect && rect.top <= lineTop + SCROLL_POSITION_EPSILON) {
currentAnchorId = messageId
}
}
if (visible.length === 0 && currentAnchorId === null) {
return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
}
return {
currentAnchorId,
visibleMessageIds: visible,
}
}
function getMessageScrollerItems(
content: HTMLElement,
spacer: HTMLElement | null
) {
return Array.from(content.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement && child !== spacer
)
}
function getNewScrollAnchor(items: HTMLElement[], previousItemCount: number) {
for (let index = previousItemCount; index < items.length; index++) {
const item = items[index]
if (item?.dataset.scrollAnchor === "true") {
return item
}
}
return null
}
function getUnanchoredScrollAnchor(
items: HTMLElement[],
handledAnchors: { has(element: HTMLElement): boolean }
) {
for (const item of items) {
if (item.dataset.scrollAnchor === "true" && !handledAnchors.has(item)) {
return item
}
}
return null
}
function hasMultipleNewScrollAnchors(
items: HTMLElement[],
previousItemCount: number
) {
let count = 0
for (let index = previousItemCount; index < items.length; index++) {
const item = items[index]
if (item?.dataset.scrollAnchor !== "true") {
continue
}
count += 1
if (count > 1) {
return true
}
}
return false
}
function getLastScrollAnchor(items: HTMLElement[]) {
for (let index = items.length - 1; index >= 0; index--) {
const item = items[index]
if (item?.dataset.scrollAnchor === "true") {
return item
}
}
return null
}
function getFirstVisibleMessageItem({
content,
spacer,
viewport,
}: {
content: HTMLElement
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const viewportRect = viewport.getBoundingClientRect()
for (const item of getMessageScrollerItems(content, spacer)) {
if (!item.dataset.messageId) {
continue
}
const rect = item.getBoundingClientRect()
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) {
return item
}
}
return null
}
function getElementScrollTop({
align,
element,
scrollMargin,
spacer,
viewport,
}: {
align: MessageScrollerScrollAlign
element: HTMLElement
scrollMargin: number
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const elementTop = getElementTop(element, viewport)
const elementHeight = element.getBoundingClientRect().height
const contentPadding = getContentBlockPadding(spacer)
if (align === "center") {
const insetHeight = Math.max(
0,
viewport.clientHeight - contentPadding.start - contentPadding.end
)
return (
elementTop -
contentPadding.start -
(insetHeight - elementHeight) / 2 -
scrollMargin
)
}
if (align === "end") {
return (
elementTop -
viewport.clientHeight +
elementHeight +
contentPadding.end +
scrollMargin
)
}
if (align === "nearest") {
const elementBottom = elementTop + elementHeight
const viewportTop = viewport.scrollTop + contentPadding.start
const viewportBottom =
viewport.scrollTop + viewport.clientHeight - contentPadding.end
if (elementTop >= viewportTop && elementBottom <= viewportBottom) {
return viewport.scrollTop
}
if (elementTop < viewportTop) {
return elementTop - contentPadding.start - scrollMargin
}
return (
elementBottom - viewport.clientHeight + contentPadding.end + scrollMargin
)
}
return elementTop - contentPadding.start - scrollMargin
}
function getElementTop(element: HTMLElement, viewport: HTMLElement) {
const elementRect = element.getBoundingClientRect()
const viewportRect = viewport.getBoundingClientRect()
return elementRect.top - viewportRect.top + viewport.scrollTop
}
function getElementViewportTop(element: HTMLElement, viewport: HTMLElement) {
return (
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
)
}
function getTailSpacerHeight({
content,
scrollTop,
spacer,
viewport,
}: {
content: HTMLElement
scrollTop: number
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const contentBottom = getContentBottom({ content, spacer, viewport })
return scrollTop + viewport.clientHeight - contentBottom
}
function getContentBottom({
content,
spacer,
viewport,
}: {
content: HTMLElement
spacer: HTMLElement | null
viewport: HTMLElement
}) {
const items = getMessageScrollerItems(content, spacer)
const padding = getBlockPadding(content)
const viewportRect = viewport.getBoundingClientRect()
const scrollTop = viewport.scrollTop
let contentBottom = padding.start + padding.end
for (const item of items) {
const rect = item.getBoundingClientRect()
contentBottom = Math.max(
contentBottom,
rect.bottom - viewportRect.top + scrollTop + padding.end
)
}
return contentBottom
}
function getMaxScrollTop(viewport: HTMLElement) {
return Math.max(0, viewport.scrollHeight - viewport.clientHeight)
}
function getBlockPadding(element: HTMLElement) {
const style = window.getComputedStyle(element)
return {
end: readCssPixel(style.paddingBlockEnd || style.paddingBottom),
start: readCssPixel(style.paddingBlockStart || style.paddingTop),
}
}
function getContentBlockPadding(spacer: HTMLElement | null) {
const content = spacer?.parentElement
if (!content) {
return {
end: 0,
start: 0,
}
}
return getBlockPadding(content)
}
function getFlexGap(element: HTMLElement | null) {
if (!element) {
return 0
}
const style = window.getComputedStyle(element)
const gap = style.rowGap === "normal" ? style.gap : style.rowGap
return readCssPixel(gap)
}
function readCssPixel(value: string | undefined) {
if (!value) {
return 0
}
const number = Number.parseFloat(value)
return Number.isFinite(number) ? number : 0
}
export {
getContentBlockPadding,
getContentBottom,
getElementScrollTop,
getElementTop,
getElementViewportTop,
getFirstVisibleMessageItem,
getFlexGap,
getLastScrollAnchor,
getMaxScrollTop,
getMessageScrollerItems,
getMessageScrollerScrollable,
getMessageScrollerVisibilityState,
getNewScrollAnchor,
getTailSpacerHeight,
getUnanchoredScrollAnchor,
hasMultipleNewScrollAnchors,
}

View File

@@ -0,0 +1,31 @@
import {
MessageScrollerButton as Button,
MessageScrollerContent as Content,
MessageScrollerItem as Item,
MessageScrollerProvider as Provider,
MessageScroller as Root,
MessageScrollerViewport as Viewport,
} from "./components"
export const MessageScroller = {
Provider,
Root,
Viewport,
Content,
Item,
Button,
}
export {
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "./components"
export type {
MessageScrollerDefaultScrollPosition,
MessageScrollerScrollAlign,
MessageScrollerScrollOptions,
MessageScrollerScrollable,
MessageScrollerVisibilityState,
} from "./types"

View File

@@ -0,0 +1,698 @@
import * as React from "react"
import { flushSync } from "react-dom"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, expect, test } from "vitest"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerVisibility,
} from "."
const MessageScrollerProvider = MessageScrollerPrimitive.Provider
const MessageScroller = MessageScrollerPrimitive.Root
const MessageScrollerViewport = MessageScrollerPrimitive.Viewport
const MessageScrollerContent = MessageScrollerPrimitive.Content
const MessageScrollerItem = MessageScrollerPrimitive.Item
const MessageScrollerButton = MessageScrollerPrimitive.Button
// Real-browser regression for the prepend double-compensation bug. In Chromium
// native scroll anchoring shifts scrollTop on prepend; the component's restore
// must NOT compensate again. This is invisible to jsdom: with no native
// anchoring there, the viewport-relative and content-relative measurements
// produce the same number, so the bug and the fix look identical. Only a real
// engine with scroll anchoring can tell them apart.
// This component is driven by the event loop — requestAnimationFrame,
// ResizeObserver, IntersectionObserver — none of which act() can wrap. The suite
// flushes renders synchronously with flushSync and settles the async scroll work
// on real frames via settle(), so it runs OUTSIDE the act environment that
// vitest.browser.setup.ts enables globally (vitest isolates files, so other
// suites keep it on). Without this, the rAF-driven visibility subscriber logs
// spurious "not wrapped in act" warnings even though the assertions, which read
// only after settle(), are correct.
;(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = false
const ITEM_HEIGHT = 80
const VIEWPORT_HEIGHT = 200
type TestItem = {
height?: number
id: string
scrollAnchor?: boolean
}
let root: Root | null = null
let container: HTMLDivElement | null = null
afterEach(() => {
root?.unmount()
container?.remove()
root = null
container = null
})
function Thread({
autoScroll,
defaultScrollPosition,
items,
scrollPreviousItemPeek,
showButton = false,
showJumpButton = false,
showVisibility = false,
}: {
autoScroll?: boolean
defaultScrollPosition?: React.ComponentProps<
typeof MessageScrollerProvider
>["defaultScrollPosition"]
items: TestItem[]
scrollPreviousItemPeek?: number
showButton?: boolean
showJumpButton?: boolean
showVisibility?: boolean
}) {
return (
<MessageScrollerProvider
autoScroll={autoScroll}
defaultScrollPosition={defaultScrollPosition}
scrollPreviousItemPeek={scrollPreviousItemPeek}
>
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{items.map((item) => (
<MessageScrollerItem
key={item.id}
messageId={item.id}
scrollAnchor={item.scrollAnchor}
style={{
height: item.height ?? ITEM_HEIGHT,
flex: "none",
}}
>
{item.id}
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
{showButton ? (
<MessageScrollerButton behavior="auto">
Scroll to end
</MessageScrollerButton>
) : null}
{showJumpButton ? <JumpButton messageId="m5" /> : null}
{showVisibility ? <VisibilityProbe /> : null}
</MessageScroller>
</MessageScrollerProvider>
)
}
function VisibilityProbe() {
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
return (
<div
data-testid="visibility"
data-current-anchor={currentAnchorId ?? ""}
data-visible={visibleMessageIds.join(",")}
/>
)
}
const MemoMessageItem = React.memo(function MemoMessageItem({
id,
anchor,
}: {
id: string
anchor: boolean
}) {
return (
<MessageScrollerItem
messageId={id}
scrollAnchor={anchor}
style={{ height: ITEM_HEIGHT, flex: "none" }}
>
{id}
</MessageScrollerItem>
)
})
function JumpButton({ messageId }: { messageId: string }) {
const { scrollToMessage } = useMessageScroller()
return (
<button
type="button"
onClick={() => {
scrollToMessage(messageId, { align: "start", behavior: "auto" })
}}
>
Jump to message
</button>
)
}
// Resolve after a few real animation frames so the component's rAF-scheduled
// scroll work and the browser's native anchoring both settle.
function settle(frames = 4) {
return new Promise<void>((resolve) => {
let remaining = frames
const tick = () =>
remaining-- <= 0 ? resolve() : requestAnimationFrame(tick)
requestAnimationFrame(tick)
})
}
function viewportOffsetOf(messageId: string, viewport: HTMLElement) {
const item = document.querySelector(
`[data-message-id="${messageId}"]`
) as HTMLElement
return Math.round(
item.getBoundingClientRect().top - viewport.getBoundingClientRect().top
)
}
function getViewport() {
return document.querySelector('[aria-label="viewport"]') as HTMLElement
}
function getDistanceToBottom(viewport: HTMLElement) {
return Math.round(
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
)
}
function getScrollTop(viewport: HTMLElement) {
return Math.round(viewport.scrollTop)
}
function getCurrentAnchor() {
return document
.querySelector('[data-testid="visibility"]')!
.getAttribute("data-current-anchor")
}
function getVisibleIds() {
const value =
document
.querySelector('[data-testid="visibility"]')!
.getAttribute("data-visible") ?? ""
return value ? value.split(",") : []
}
async function renderThread(props: React.ComponentProps<typeof Thread>) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(<Thread {...props} />)
})
await settle()
}
function createItems(count: number) {
return Array.from({ length: count }, (_, index) => ({
id: `m${index}`,
}))
}
test("keeps the visible message in place when older messages are prepended", async () => {
const initial = createItems(8)
await renderThread({ items: initial })
const viewport = getViewport()
// Scroll into the middle so there is content above and below; m3 lands at the
// top of the viewport and becomes the preserved anchor.
viewport.scrollTop = 3 * ITEM_HEIGHT
await settle()
const offsetBefore = viewportOffsetOf("m3", viewport)
// Prepend three older rows above the current scroll position.
flushSync(() => {
root!.render(
<Thread items={[{ id: "o0" }, { id: "o1" }, { id: "o2" }, ...initial]} />
)
})
await settle()
const offsetAfter = viewportOffsetOf("m3", viewport)
// The tracked message must not move within the viewport. With the old
// content-relative restore it jumps up by the prepended height (~240px).
expect(Math.abs(offsetAfter - offsetBefore)).toBeLessThanOrEqual(1)
})
test("opens at the bottom by default", async () => {
await renderThread({ items: createItems(8) })
expect(getDistanceToBottom(getViewport())).toBeLessThanOrEqual(1)
})
test("keeps auto-scroll pinned when the final message grows", async () => {
const initial = createItems(6)
await renderThread({ autoScroll: true, items: initial })
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(
<Thread
autoScroll
items={initial.map((item, index) =>
index === initial.length - 1 ? { ...item, height: 240 } : item
)}
/>
)
})
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
})
test("keeps the end pinned when bulk appending anchored turns with autoScroll", async () => {
const initial = createItems(6)
await renderThread({ autoScroll: true, items: initial })
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(
<Thread
autoScroll
items={[
...initial,
{ id: "new-user-1", scrollAnchor: true },
{ id: "new-assistant-1" },
{ id: "new-user-2", scrollAnchor: true },
{ id: "new-assistant-2" },
]}
/>
)
})
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
})
test("does not keep the end pinned when appending after the default bottom open", async () => {
const initial = createItems(6)
await renderThread({ items: initial })
const viewport = getViewport()
const scrollTop = getScrollTop(viewport)
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(<Thread items={[...initial, { id: "new" }]} />)
})
await settle()
expect(getScrollTop(viewport)).toBe(scrollTop)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
})
test("scroll button moves the viewport to the end", async () => {
await renderThread({
defaultScrollPosition: "start",
items: createItems(8),
showButton: true,
})
const viewport = getViewport()
expect(getScrollTop(viewport)).toBe(0)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
const button = document.querySelector("button") as HTMLButtonElement
button.click()
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
})
test("restores the last scroll anchor when the final turn overflows", async () => {
await renderThread({
defaultScrollPosition: "last-anchor",
items: [
{ id: "m0" },
{ id: "m1" },
{ id: "last-user", scrollAnchor: true },
{ id: "last-assistant", height: 360 },
],
scrollPreviousItemPeek: 0,
})
const viewport = getViewport()
expect(viewportOffsetOf("last-user", viewport)).toBeLessThanOrEqual(1)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
})
test("falls back to the end when the last anchored turn fits", async () => {
await renderThread({
defaultScrollPosition: "last-anchor",
items: [
...createItems(5),
{ id: "last-user", scrollAnchor: true },
{ id: "last-assistant" },
],
scrollPreviousItemPeek: 0,
})
expect(getDistanceToBottom(getViewport())).toBeLessThanOrEqual(1)
})
test("scrolls to a mounted message by id", async () => {
await renderThread({
defaultScrollPosition: "start",
items: createItems(8),
showJumpButton: true,
})
const viewport = getViewport()
const button = document.querySelector("button") as HTMLButtonElement
button.click()
await settle()
expect(viewportOffsetOf("m5", viewport)).toBeLessThanOrEqual(1)
})
test("preserves a scrolled-to turn across a prepend (command-path anchor)", async () => {
// Characterization lock for the Phase-5 anchor-ref boundary: scrollToMessage
// sets the preserve anchor OUTSIDE a content change (the command path), unlike
// every other prepend test which scrolls directly. Jumping then prepending
// must keep the jumped-to turn fixed, not lose its anchor.
const initial = createItems(12)
await renderThread({
defaultScrollPosition: "start",
items: initial,
showJumpButton: true,
})
const viewport = getViewport()
;(document.querySelector("button") as HTMLButtonElement).click()
await settle()
const offsetBefore = viewportOffsetOf("m5", viewport)
expect(offsetBefore).toBeLessThanOrEqual(1)
flushSync(() => {
root!.render(
<Thread
defaultScrollPosition="start"
items={[{ id: "o0" }, { id: "o1" }, { id: "o2" }, ...initial]}
showJumpButton
/>
)
})
await settle()
expect(
Math.abs(viewportOffsetOf("m5", viewport) - offsetBefore)
).toBeLessThanOrEqual(1)
})
test("user scroll intent cancels follow-bottom", async () => {
const initial = createItems(8)
await renderThread({ autoScroll: true, items: initial })
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
viewport.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, deltaY: -ITEM_HEIGHT })
)
viewport.scrollTop = 0
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
await settle()
expect(getScrollTop(viewport)).toBe(0)
flushSync(() => {
root!.render(<Thread autoScroll items={[...initial, { id: "new" }]} />)
})
await settle()
expect(getScrollTop(viewport)).toBe(0)
expect(getDistanceToBottom(viewport)).toBeGreaterThan(0)
})
test("tracks the current anchor as it scrolls above the viewport", async () => {
// Every tenth row is a turn-start anchor: m0, m10, m20.
const items = Array.from({ length: 30 }, (_, index) => ({
id: `m${index}`,
scrollAnchor: index % 10 === 0,
}))
await renderThread({
defaultScrollPosition: "start",
items,
showVisibility: true,
})
const viewport = getViewport()
// Opened at the top → the first anchor is current. The subscribed probe
// settles on a post-mount rAF, so the value is only correct after the settle()
// that renderThread awaits.
expect(getCurrentAnchor()).toBe("m0")
// Scroll until m10 is fully above the viewport. It must stay current even
// though it is no longer visible — the behavior the old activeMessageId (and
// jsdom) could not express.
viewport.scrollTop = 900
await settle()
expect(getCurrentAnchor()).toBe("m10")
expect(getVisibleIds()).not.toContain("m10")
// Scrolling past the next anchor advances the cursor.
viewport.scrollTop = 1700
await settle()
expect(getCurrentAnchor()).toBe("m20")
})
test("keeps the anchor at the reading line current over lower visible anchors", async () => {
// Every row is a turn-start anchor, so several are on screen at once — the
// scrollToMessage case. Jumping to a turn must keep IT current even though
// newer anchors sit below it and are still visible (the regression where the
// lowest visible anchor wrongly stole "current").
const items = Array.from({ length: 12 }, (_, index) => ({
id: `m${index}`,
scrollAnchor: true,
}))
await renderThread({
defaultScrollPosition: "start",
items,
showVisibility: true,
})
const viewport = getViewport()
// Bring m5 up to the reading line; m6 and m7 sit below it, also visible.
viewport.scrollTop = 5 * ITEM_HEIGHT
await settle()
expect(getCurrentAnchor()).toBe("m5")
// m6 is on screen but below the line — it must not steal current.
expect(getVisibleIds()).toContain("m6")
})
test("visibility populates under StrictMode (frame ref + lifecycle survive remount)", async () => {
const ids = Array.from({ length: 8 }, (_, index) => `m${index}`)
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
// StrictMode double-invokes mount → unmount → remount on the same refs, which
// is what wedges the rAF scheduler and tears the subscription in Next dev.
flushSync(() => {
root!.render(
<React.StrictMode>
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{ids.map((id, index) => (
<MemoMessageItem key={id} id={id} anchor={index % 2 === 0} />
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<VisibilityProbe />
</MessageScroller>
</MessageScrollerProvider>
</React.StrictMode>
)
})
await settle()
// Force a recompute after StrictMode's mount churn.
getViewport().scrollTop = 0
getViewport().dispatchEvent(new Event("scroll", { bubbles: true }))
await settle()
expect(getCurrentAnchor()).toBe("m0")
expect(getVisibleIds().length).toBeGreaterThan(0)
})
test("tracks visibility through memoized item components", async () => {
const ids = Array.from({ length: 8 }, (_, index) => `m${index}`)
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{ids.map((id, index) => (
<MemoMessageItem key={id} id={id} anchor={index % 2 === 0} />
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<VisibilityProbe />
</MessageScroller>
</MessageScrollerProvider>
)
})
await settle()
// currentAnchorId scans the DOM (memo-independent). visibleMessageIds rides
// registration + the real IntersectionObserver — the path memo could break.
expect(getCurrentAnchor()).toBe("m0")
expect(getVisibleIds().length).toBeGreaterThan(0)
expect(getVisibleIds()).toContain("m0")
})
test("an anchored turn holds at the top when content below it collapses", async () => {
const peek = 32
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
const base = [
{ id: "m0", height: 300 },
{ id: "m1", height: 300 },
{ id: "m2", height: 300 },
]
flushSync(() => {
root!.render(<Thread items={base} scrollPreviousItemPeek={peek} />)
})
await settle()
// A new turn (anchor) arrives with a transient marker below it — like the
// demo's "Thinking..." placeholder — so it has content below to reach the top.
flushSync(() => {
root!.render(
<Thread
scrollPreviousItemPeek={peek}
items={[
...base,
{ id: "turn", height: 80, scrollAnchor: true },
{ id: "marker", height: 100 },
]}
/>
)
})
await settle()
expect(viewportOffsetOf("turn", getViewport())).toBeLessThanOrEqual(peek + 4)
// The marker is replaced by an empty reply that will stream in: the content
// below the turn collapses. The turn must stay pinned at the top, not drop as
// the browser clamps scrollTop to the shorter content.
flushSync(() => {
root!.render(
<Thread
scrollPreviousItemPeek={peek}
items={[
...base,
{ id: "turn", height: 80, scrollAnchor: true },
{ id: "reply", height: 0 },
]}
/>
)
})
await settle()
await settle()
expect(viewportOffsetOf("turn", getViewport())).toBeLessThanOrEqual(peek + 4)
})
test("auto-scroll and content updates survive a StrictMode remount", async () => {
// StrictMode mounts, unmounts, then remounts on the same refs. The existing
// StrictMode test covers the visibility frame; this one covers the rest of the
// machinery — the state + scroll frame refs must reset on the remount so
// follow-bottom keeps working, not just on first mount.
const items = createItems(6)
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(
<React.StrictMode>
<Thread autoScroll items={items} showVisibility />
</React.StrictMode>
)
})
await settle()
const viewport = getViewport()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
// A new message after the remount still drives the content-change + scroll path.
flushSync(() => {
root!.render(
<React.StrictMode>
<Thread autoScroll items={[...items, { id: "new" }]} showVisibility />
</React.StrictMode>
)
})
await settle()
expect(getDistanceToBottom(viewport)).toBeLessThanOrEqual(1)
expect(getVisibleIds().length).toBeGreaterThan(0)
})

View File

@@ -0,0 +1,841 @@
import * as React from "react"
import { flushSync } from "react-dom"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, expect, test } from "vitest"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScrollerVisibility,
} from "."
import { getMessageScrollerScrollable } from "./geometry"
const MessageScrollerProvider = MessageScrollerPrimitive.Provider
const MessageScroller = MessageScrollerPrimitive.Root
const MessageScrollerViewport = MessageScrollerPrimitive.Viewport
const MessageScrollerContent = MessageScrollerPrimitive.Content
const MessageScrollerItem = MessageScrollerPrimitive.Item
// Performance proof for MessageScroller at scale. Two layers:
//
// Layer 1 isolates the algorithm: getMessageScrollerScrollable() drives the O(n)
// getContentBottom() scan that runs on every scroll commit. We measure its cost
// against a real laid-out DOM as message count grows, forcing a reflow each
// iteration the way real scrolling does.
//
// Layer 2 is the integration proof: a real top-to-bottom scroll on a large
// transcript, timing the synchronous scroll handler (where the geometry runs)
// and the resulting frame cadence, with both scroll state and visibility
// subscribed like a real chat app.
//
// jsdom cannot run either: it stubs getBoundingClientRect to a free property
// read, so the forced layout that IS the cost here would not exist.
const ITEM_HEIGHT = 48
const VIEWPORT_HEIGHT = 480
const FRAME_BUDGET = 16 // One 60fps frame in ms.
let root: Root | null = null
let container: HTMLDivElement | null = null
// Performance benchmarks are not React act() tests: they drive real
// requestAnimationFrame / IntersectionObserver and measure wall-clock, and never
// assert on rendered output. Opt this file out of the act environment that
// vitest.browser.setup.ts enables globally — vitest isolates files, so the
// behavior tests keep it on. Renders we time are flushed synchronously with
// flushSync (which runs layout effects in-band); everything else settles on real
// frames. Without this flag, the live visibility subscriber's rAF/IO-driven store
// updates — which act() cannot wrap — would log spurious "not wrapped in act"
// warnings.
;(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = false
afterEach(() => {
root?.unmount()
container?.remove()
root = null
container = null
})
function VisibilityProbe() {
// Subscribing turns on the visibility recompute path, so Layer 2 reflects a
// real app that reads currentAnchorId, not just the scroll-state path.
const { currentAnchorId } = useMessageScrollerVisibility()
return (
<div data-testid="visibility" data-current-anchor={currentAnchorId ?? ""} />
)
}
function Thread({
count,
showVisibility = false,
}: {
count: number
showVisibility?: boolean
}) {
return (
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{Array.from({ length: count }, (_, index) => (
<MessageScrollerItem
key={index}
messageId={`m${index}`}
// Every eighth row is a turn-start anchor, like real transcripts.
scrollAnchor={index % 8 === 0}
style={{ height: ITEM_HEIGHT, flex: "none" }}
>
{`m${index}`}
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
{showVisibility ? <VisibilityProbe /> : null}
</MessageScroller>
</MessageScrollerProvider>
)
}
function nextFrame() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
async function mountThread(count: number, showVisibility = false) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(<Thread count={count} showVisibility={showVisibility} />)
})
// Let the component's mount-time scroll work settle before measuring.
await nextFrame()
await nextFrame()
await nextFrame()
const viewport = document.querySelector(
'[aria-label="viewport"]'
) as HTMLElement
const content = viewport.querySelector('[role="log"]') as HTMLElement
const spacer = content.querySelector(
"[data-message-scroller-spacer]"
) as HTMLElement
return { content, spacer, viewport }
}
function median(values: number[]) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
function percentile(values: number[], p: number) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[
Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))
]
}
function round(value: number) {
return Math.round(value * 1000) / 1000
}
test("Layer 1 — getContentBottom scan stays sub-frame as message count grows", async () => {
const counts = [100, 500, 1000, 2000]
const iterations = 200
const rows: Array<{
n: number
medianMs: number
p95Ms: number
maxMs: number
}> = []
for (const n of counts) {
const { content, spacer, viewport } = await mountThread(n)
const samples: number[] = []
// Warm up so JIT and first-layout costs do not pollute the samples.
for (let i = 0; i < 20; i++) {
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
}
for (let i = 0; i < iterations; i++) {
// Toggle scrollTop to dirty layout, so the getBoundingClientRect calls
// inside getMessageScrollerScrollable force a real reflow — exactly what
// happens frame-to-frame during a real scroll. Without this the browser
// would serve cached rects and the benchmark would measure nothing.
viewport.scrollTop = 1000 + (i % 2)
const start = performance.now()
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
samples.push(performance.now() - start)
}
rows.push({
n,
medianMs: round(median(samples)),
p95Ms: round(percentile(samples, 95)),
maxMs: round(Math.max(...samples)),
})
root?.unmount()
container?.remove()
root = null
container = null
}
// eslint-disable-next-line no-console
console.log(
"\n[Layer 1] getMessageScrollerScrollable cost per scroll commit (real reflow):\n" +
rows
.map(
(r) =>
` n=${String(r.n).padStart(4)} median ${String(
r.medianMs
).padStart(
6
)}ms p95 ${String(r.p95Ms).padStart(6)}ms max ${String(
r.maxMs
).padStart(6)}ms`
)
.join("\n") +
"\n"
)
// Proof: even at 2000 rendered rows a single commit stays under one frame,
// with generous CI slack. The logged median is the real headline number.
const worst = rows[rows.length - 1]
expect(worst.medianMs).toBeLessThan(FRAME_BUDGET)
})
test("Layer 2 — real top-to-bottom scroll on 1000 messages holds frame budget", async () => {
const COUNT = 1000
const STEPS = 120
const { viewport } = await mountThread(COUNT, true)
// Capture any main-thread long task (>50ms by spec) during the scroll.
const longTasks: number[] = []
let observer: PerformanceObserver | null = null
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ["longtask"] })
} catch {
// longtask not supported — frame timing below is still the primary proof.
}
const max = viewport.scrollHeight - viewport.clientHeight
const handlerCosts: number[] = []
const frameDeltas: number[] = []
let last = performance.now()
for (let i = 1; i <= STEPS; i++) {
viewport.scrollTop = Math.round((max * i) / STEPS)
// The scroll handler runs synchronously inside dispatch:
// syncAfterScroll -> commitScrollState -> getContentBottom. Timing the
// dispatch is timing the exact main-thread block a user scroll causes.
const start = performance.now()
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
handlerCosts.push(performance.now() - start)
await nextFrame()
const now = performance.now()
frameDeltas.push(now - last)
last = now
}
observer?.disconnect()
const stats = {
handlerMedianMs: round(median(handlerCosts)),
handlerP95Ms: round(percentile(handlerCosts, 95)),
handlerMaxMs: round(Math.max(...handlerCosts)),
frameMedianMs: round(median(frameDeltas)),
frameP95Ms: round(percentile(frameDeltas, 95)),
frameMaxMs: round(Math.max(...frameDeltas)),
longTasks: longTasks.length,
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
}
// eslint-disable-next-line no-console
console.log(
`\n[Layer 2] scrolling ${COUNT} messages top-to-bottom over ${STEPS} steps` +
" (scroll state + visibility live):\n" +
` scroll handler median ${stats.handlerMedianMs}ms p95 ${stats.handlerP95Ms}ms max ${stats.handlerMaxMs}ms\n` +
` frame interval median ${stats.frameMedianMs}ms p95 ${stats.frameP95Ms}ms max ${stats.frameMaxMs}ms\n` +
` long tasks ${stats.longTasks}${
stats.longTasks ? ` (worst ${stats.longTaskMaxMs}ms)` : ""
}\n`
)
// Proof: the synchronous scroll handler never spends a whole frame, so it
// cannot drop one. Median is the headline; p95 carries generous CI slack.
expect(stats.handlerMedianMs).toBeLessThan(FRAME_BUDGET)
expect(stats.handlerP95Ms).toBeLessThan(FRAME_BUDGET * 2)
})
test("Regression guard — one scroll commit does O(items) layout reads, not O(items^2)", async () => {
// Timing budgets are a coarse, hardware-dependent backstop. THIS is the real
// regression guard: the number of forced layout reads per scroll commit is
// deterministic and machine-independent. getContentBottom reads one rect per
// item (plus a small constant), so the ratio stays ~O(1) per item. An O(n^2)
// regression — e.g. a nested rect loop — would push it far past the bound.
const ITEMS = 2000
const { viewport } = await mountThread(ITEMS)
const originalRect = Element.prototype.getBoundingClientRect
let calls = 0
Element.prototype.getBoundingClientRect = function getBoundingClientRectSpy(
this: Element
) {
calls++
return originalRect.call(this)
}
try {
// One synchronous scroll commit (handleScroll runs inside dispatch).
viewport.scrollTop = 1234
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
} finally {
Element.prototype.getBoundingClientRect = originalRect
}
const perItem = round(calls / ITEMS)
// eslint-disable-next-line no-console
console.log(
`\n[Regression guard] ${calls} getBoundingClientRect calls for ${ITEMS} items` +
` (${perItem}/item) in one scroll commit\n`
)
// A handful of rect reads per item is fine; anything that scales with item
// count (O(n) per item -> O(n^2) total) blows past this at 2000 items.
expect(perItem).toBeLessThan(4)
})
// ===========================================================================
// Layer 3 — streaming. The one path where heavy DOM should actually bite: an
// assistant reply GROWS token-by-token at the bottom of a long transcript.
// Growth dirties layout, so handleContentChange + the ResizeObserver-driven
// handleResize run while a full reflow is genuinely required — unlike steady
// scroll, which never dirties layout.
//
// To isolate the SCROLLER's cost from the markdown's own render cost (they
// interleave in one React commit), we run the identical growth twice: once
// inside MessageScroller (auto-follow) and once inside a plain scroll div
// (manual follow). Prior history is memoized in both, like a real app, so only
// the growing reply re-renders. The delta is what the scroller adds per token.
// ===========================================================================
const PRIOR_TURNS = 150
const STREAM_CHUNKS = 60
function StreamingReplyBody({ chunks }: { chunks: number }) {
// Each token batch appends a block, so the reply grows and dirties layout.
return (
<div style={{ padding: "12px 16px", fontSize: 14, lineHeight: 1.6 }}>
{Array.from({ length: chunks }, (_, index) =>
index % 5 === 4 ? (
<CodeBlock key={index} lines={6} />
) : (
<p key={index} style={{ margin: "0 0 8px" }}>
{`Streaming sentence ${index} that adds height to the reply as it grows. `.repeat(
2
)}
</p>
)
)}
</div>
)
}
// Memoized prior history — stable across token renders, so streaming only
// re-renders the growing reply, exactly like a real memoized message list.
const PriorTurnsScroller = React.memo(function PriorTurnsScroller() {
return (
<>
{Array.from({ length: PRIOR_TURNS }, (_, index) => (
<React.Fragment key={index}>
<MessageScrollerItem
messageId={`u${index}`}
scrollAnchor
style={{
flex: "none",
padding: "12px 16px",
background: "#fafafa",
}}
>
{`Question ${index}`}
</MessageScrollerItem>
<MessageScrollerItem messageId={`a${index}`} style={{ flex: "none" }}>
<HeavyAssistantMessage seed={index} />
</MessageScrollerItem>
</React.Fragment>
))}
</>
)
})
const PriorTurnsPlain = React.memo(function PriorTurnsPlain() {
return (
<>
{Array.from({ length: PRIOR_TURNS }, (_, index) => (
<React.Fragment key={index}>
<div style={{ padding: "12px 16px", background: "#fafafa" }}>
{`Question ${index}`}
</div>
<div>
<HeavyAssistantMessage seed={index} />
</div>
</React.Fragment>
))}
</>
)
})
function ScrollerStream({ chunks }: { chunks: number }) {
return (
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
<PriorTurnsScroller />
<MessageScrollerItem messageId="streaming" style={{ flex: "none" }}>
<StreamingReplyBody chunks={chunks} />
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
)
}
function PlainStream({ chunks }: { chunks: number }) {
return (
<div
data-testid="plain-viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<PriorTurnsPlain />
<div>
<StreamingReplyBody chunks={chunks} />
</div>
</div>
</div>
)
}
async function runStream(
renderTree: (chunks: number) => React.ReactElement,
follow?: (viewport: HTMLElement) => void
) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(renderTree(1))
})
await nextFrame()
await nextFrame()
await nextFrame()
const viewport = (container.querySelector('[aria-label="viewport"]') ??
container.querySelector('[data-testid="plain-viewport"]')) as HTMLElement
const longTasks: number[] = []
let observer: PerformanceObserver | null = null
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ["longtask"] })
} catch {
// longtask unsupported.
}
const commits: number[] = []
for (let chunk = 2; chunk <= STREAM_CHUNKS; chunk++) {
// Time the synchronous React commit: render + layout effects, where the
// scroller's handleContentChange runs. The reply is one chunk taller, so
// layout is dirty going in.
const start = performance.now()
flushSync(() => {
root!.render(renderTree(chunk))
})
commits.push(performance.now() - start)
follow?.(viewport)
await nextFrame()
}
observer?.disconnect()
root?.unmount()
container?.remove()
root = null
container = null
return {
commitMedianMs: round(median(commits)),
commitP95Ms: round(percentile(commits, 95)),
commitMaxMs: round(Math.max(...commits)),
longTasks: longTasks.length,
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
}
}
test("Layer 3 — streaming a heavy reply: scroller overhead vs plain baseline", async () => {
// Baseline: same growth in a plain scroll div, manually following the bottom.
const baseline = await runStream(
(chunks) => <PlainStream chunks={chunks} />,
(viewport) => {
viewport.scrollTop = viewport.scrollHeight
}
)
// Scroller: identical growth, auto-follow does the work.
const scroller = await runStream((chunks) => (
<ScrollerStream chunks={chunks} />
))
const overheadMedian = round(
scroller.commitMedianMs - baseline.commitMedianMs
)
// eslint-disable-next-line no-console
console.log(
`\n[Layer 3] streaming a reply to ${STREAM_CHUNKS} chunks below ${PRIOR_TURNS} memoized heavy turns,` +
" per-token commit (render + scroller content-change handling):\n" +
` plain div baseline median ${baseline.commitMedianMs}ms p95 ${baseline.commitP95Ms}ms max ${baseline.commitMaxMs}ms longTasks ${baseline.longTasks}\n` +
` MessageScroller median ${scroller.commitMedianMs}ms p95 ${scroller.commitP95Ms}ms max ${scroller.commitMaxMs}ms longTasks ${scroller.longTasks}\n` +
` scroller overhead median ${overheadMedian}ms per token\n`
)
// Proof: the scroller's per-token streaming work fits in a frame, and the
// overhead it adds over a plain scroll div is a small fraction of one.
expect(scroller.commitMedianMs).toBeLessThan(FRAME_BUDGET)
expect(overheadMedian).toBeLessThan(FRAME_BUDGET)
})
// ---------------------------------------------------------------------------
// Heavy-content variant. Real chat turns are not one text node — they are full
// markdown subtrees: headings, paragraphs, tokenized code blocks, tables and
// lists, hundreds of DOM nodes each. The scroller's forced reflow recomputes
// layout over that whole subtree, so this is the honest stress for the geometry
// hot path. (This does NOT measure the markdown library's own render cost —
// that is the content's concern, not the scroller's.)
// ---------------------------------------------------------------------------
function CodeBlock({ lines }: { lines: number }) {
return (
<pre
style={{
whiteSpace: "pre",
overflowX: "auto",
background: "#f5f5f5",
padding: 12,
margin: "8px 0",
fontFamily: "monospace",
fontSize: 13,
}}
>
<code>
{Array.from({ length: lines }, (_, line) => (
<div key={line}>
{Array.from({ length: 8 }, (_, token) => (
<span key={token} style={{ color: token % 2 ? "#07a" : "#905" }}>
{`tok${token} `}
</span>
))}
</div>
))}
</code>
</pre>
)
}
function MarkdownTable({ rows }: { rows: number }) {
return (
<table
style={{ borderCollapse: "collapse", margin: "8px 0", width: "100%" }}
>
<thead>
<tr>
{["Name", "Type", "Default", "Description"].map((header) => (
<th key={header} style={{ border: "1px solid #ddd", padding: 6 }}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }, (_, row) => (
<tr key={row}>
{Array.from({ length: 4 }, (_, cell) => (
<td key={cell} style={{ border: "1px solid #ddd", padding: 6 }}>
{`r${row}c${cell}`}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
function HeavyAssistantMessage({ seed }: { seed: number }) {
// Vary the sizes by turn so heights are non-uniform like real replies.
const paragraphs = 2 + (seed % 3)
const codeLines = 12 + (seed % 14)
const tableRows = 3 + (seed % 5)
return (
<div style={{ padding: "12px 16px", fontSize: 14, lineHeight: 1.6 }}>
<h2 style={{ fontSize: 18, margin: "0 0 8px" }}>{`Reply ${seed}`}</h2>
{Array.from({ length: paragraphs }, (_, index) => (
<p key={index} style={{ margin: "0 0 8px" }}>
{`This is paragraph ${index} of a fairly long assistant reply that wraps across multiple lines so the layout engine has real flow work to do when the surrounding scroll position changes during a scroll. `.repeat(
2
)}
</p>
))}
<ul style={{ margin: "0 0 8px", paddingLeft: 20 }}>
{Array.from({ length: 5 }, (_, index) => (
<li key={index}>{`List item ${index} with some inline text.`}</li>
))}
</ul>
<CodeBlock lines={codeLines} />
<MarkdownTable rows={tableRows} />
</div>
)
}
function HeavyThread({ turns }: { turns: number }) {
return (
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport
aria-label="viewport"
style={{ height: VIEWPORT_HEIGHT, overflowY: "auto" }}
>
<MessageScrollerContent
style={{ display: "flex", flexDirection: "column" }}
>
{Array.from({ length: turns }, (_, index) => (
<React.Fragment key={index}>
{/* User turn: short, and the scroll anchor. */}
<MessageScrollerItem
messageId={`u${index}`}
scrollAnchor
style={{
flex: "none",
padding: "12px 16px",
background: "#fafafa",
}}
>
{`Question ${index}: how does this work?`}
</MessageScrollerItem>
{/* Assistant turn: heavy markdown subtree. */}
<MessageScrollerItem
messageId={`a${index}`}
style={{ flex: "none" }}
>
<HeavyAssistantMessage seed={index} />
</MessageScrollerItem>
</React.Fragment>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<VisibilityProbe />
</MessageScroller>
</MessageScrollerProvider>
)
}
async function mountHeavyThread(turns: number) {
container = document.createElement("div")
document.body.appendChild(container)
root = createRoot(container)
flushSync(() => {
root!.render(<HeavyThread turns={turns} />)
})
await nextFrame()
await nextFrame()
await nextFrame()
const viewport = document.querySelector(
'[aria-label="viewport"]'
) as HTMLElement
const content = viewport.querySelector('[role="log"]') as HTMLElement
const spacer = content.querySelector(
"[data-message-scroller-spacer]"
) as HTMLElement
const nodeCount = content.querySelectorAll("*").length
return { content, nodeCount, spacer, viewport }
}
test("Layer 1 (heavy markdown) — commit cost over full markdown subtrees", async () => {
const turnCounts = [50, 150, 300]
const iterations = 150
const rows: Array<{
turns: number
items: number
nodes: number
medianMs: number
p95Ms: number
maxMs: number
}> = []
for (const turns of turnCounts) {
const { content, nodeCount, spacer, viewport } =
await mountHeavyThread(turns)
const samples: number[] = []
for (let i = 0; i < 20; i++) {
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
}
for (let i = 0; i < iterations; i++) {
viewport.scrollTop = 1000 + (i % 2)
const start = performance.now()
getMessageScrollerScrollable({
content,
scrollEdgeThreshold: 8,
spacer,
viewport,
})
samples.push(performance.now() - start)
}
rows.push({
turns,
items: turns * 2,
nodes: nodeCount,
medianMs: round(median(samples)),
p95Ms: round(percentile(samples, 95)),
maxMs: round(Math.max(...samples)),
})
root?.unmount()
container?.remove()
root = null
container = null
}
// eslint-disable-next-line no-console
console.log(
"\n[Layer 1 · heavy markdown] getMessageScrollerScrollable cost per commit:\n" +
rows
.map(
(r) =>
` turns=${String(r.turns).padStart(3)} items=${String(
r.items
).padStart(3)} domNodes=${String(r.nodes).padStart(
6
)} median ${String(r.medianMs).padStart(6)}ms p95 ${String(
r.p95Ms
).padStart(6)}ms max ${String(r.maxMs).padStart(6)}ms`
)
.join("\n") +
"\n"
)
const worst = rows[rows.length - 1]
expect(worst.medianMs).toBeLessThan(FRAME_BUDGET)
})
test("Layer 2 (heavy markdown) — real scroll over 300 markdown turns", async () => {
const TURNS = 300
const STEPS = 120
const { nodeCount, viewport } = await mountHeavyThread(TURNS)
const longTasks: number[] = []
let observer: PerformanceObserver | null = null
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ["longtask"] })
} catch {
// longtask unsupported — handler timing below is still the proof.
}
const max = viewport.scrollHeight - viewport.clientHeight
const handlerCosts: number[] = []
for (let i = 1; i <= STEPS; i++) {
viewport.scrollTop = Math.round((max * i) / STEPS)
const start = performance.now()
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
handlerCosts.push(performance.now() - start)
await nextFrame()
}
observer?.disconnect()
const stats = {
handlerMedianMs: round(median(handlerCosts)),
handlerP95Ms: round(percentile(handlerCosts, 95)),
handlerMaxMs: round(Math.max(...handlerCosts)),
longTasks: longTasks.length,
longTaskMaxMs: longTasks.length ? round(Math.max(...longTasks)) : 0,
}
// eslint-disable-next-line no-console
console.log(
`\n[Layer 2 · heavy markdown] scrolling ${TURNS} turns (${
TURNS * 2
} messages, ${nodeCount} DOM nodes) top-to-bottom:\n` +
` scroll handler median ${stats.handlerMedianMs}ms p95 ${stats.handlerP95Ms}ms max ${stats.handlerMaxMs}ms\n` +
` long tasks ${stats.longTasks}${
stats.longTasks ? ` (worst ${stats.longTaskMaxMs}ms)` : ""
}\n`
)
expect(stats.handlerMedianMs).toBeLessThan(FRAME_BUDGET)
expect(stats.handlerP95Ms).toBeLessThan(FRAME_BUDGET * 2)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
import { EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE } from "./types"
import type {
MessageScrollerScrollable,
MessageScrollerStore,
MessageScrollerVisibilityState,
MessageScrollerVisibilityStore,
} from "./types"
// Generic useSyncExternalStore backing: a stable snapshot (referentially equal
// while isEqual holds, so subscribers only re-render on real transitions) plus
// optional reference-counting via the subscribe-time lifecycle callbacks.
function createExternalStore<T>(
initialSnapshot: T,
isEqual: (a: T, b: T) => boolean
) {
let snapshot = initialSnapshot
const listeners = new Set<() => void>()
return {
getSnapshot: () => snapshot,
hasListeners: () => listeners.size > 0,
setSnapshot: (nextSnapshot: T) => {
if (isEqual(snapshot, nextSnapshot)) {
return
}
snapshot = nextSnapshot
listeners.forEach((listener) => listener())
},
subscribe: (
listener: () => void,
onFirstSubscribe?: () => void,
onLastUnsubscribe?: () => void
) => {
const wasEmpty = listeners.size === 0
listeners.add(listener)
if (wasEmpty) {
onFirstSubscribe?.()
}
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
onLastUnsubscribe?.()
}
}
},
}
}
function createMessageScrollerStore<T>(
initialSnapshot: T,
isEqual: (a: T, b: T) => boolean
): MessageScrollerStore<T> {
return createExternalStore(initialSnapshot, isEqual)
}
function createMessageScrollerVisibilityStore(): MessageScrollerVisibilityStore {
return createExternalStore(
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
areVisibilityStatesEqual
)
}
function areScrollStatesEqual(
current: MessageScrollerScrollable,
next: MessageScrollerScrollable
) {
return current.start === next.start && current.end === next.end
}
function areVisibilityStatesEqual(
current: MessageScrollerVisibilityState,
next: MessageScrollerVisibilityState
) {
if (current.currentAnchorId !== next.currentAnchorId) {
return false
}
if (current.visibleMessageIds.length !== next.visibleMessageIds.length) {
return false
}
return current.visibleMessageIds.every(
(messageId, index) => messageId === next.visibleMessageIds[index]
)
}
export {
areScrollStatesEqual,
areVisibilityStatesEqual,
createMessageScrollerStore,
createMessageScrollerVisibilityStore,
}

View File

@@ -0,0 +1,240 @@
import * as React from "react"
import type { UseRenderComponentProps } from "../use-render"
// Default scrollEdgeThreshold. Sub-pixel tolerance so edge detection does not
// flicker across engines that round scrollTop differently.
const DEFAULT_SCROLL_EDGE_THRESHOLD = 8
// Default scrollPreviousItemPeek. Pixels of the previous item kept visible above
// a newly anchored row.
const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64
// Default scrollMargin for scrollToMessage and programmatic targets.
const DEFAULT_SCROLL_MARGIN = 0
// Two fractional scrollTop values within this range are treated as equal, to
// absorb zoom and HiDPI rounding drift.
const SCROLL_POSITION_EPSILON = 0.5
// How long (ms) data-autoscrolling stays set during a programmatic smooth scroll
// before clearing.
const AUTOSCROLLING_CLEAR_DELAY = 180
// Viewport keys that count as deliberate scroll intent and release follow-bottom.
const USER_SCROLL_KEYS = new Set([
"ArrowDown",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
" ", // Space key.
])
// Internal scroll mode. Derived from intent and commands; decides how the
// viewport reacts to content and resize.
type MessageScrollerMode =
| "following-bottom" // autoScroll on, pinned to the latest message.
| "free-scrolling" // reader scrolled away; position left alone (prepends still preserved).
| "anchored-to-message" // holding a turn at the reading line while it streams.
| "settling-jump" // a programmatic jump is animating; intent detection suppressed until it settles.
// Where a saved transcript opens on the first non-empty render.
type MessageScrollerDefaultScrollPosition = "start" | "end" | "last-anchor"
// Which transcript edge MessageScrollerButton scrolls toward.
type MessageScrollerButtonDirection = "start" | "end"
// Viewport alignment for scrollToMessage and programmatic jumps.
type MessageScrollerScrollAlign = "start" | "center" | "end" | "nearest"
// Options for scrollToMessage, scrollToEnd, and scrollToStart.
type MessageScrollerScrollOptions = {
// Viewport edge or center to align the target to.
align?: MessageScrollerScrollAlign
// Native scroll behavior.
behavior?: ScrollBehavior
// Margin on the aligned edge, in pixels. Defaults to the provider scrollMargin.
scrollMargin?: number
}
// Scroll snapshot from useMessageScrollerScrollable: which edges the viewport can
// still scroll toward.
type MessageScrollerScrollable = {
// The viewport can scroll toward the start (content is hidden above).
start: boolean
// The viewport can scroll toward the end (content is hidden below).
end: boolean
}
// Visibility snapshot from useMessageScrollerVisibility.
type MessageScrollerVisibilityState = {
// The anchored turn the reader is in, or null. Stays set after the anchor
// scrolls above the viewport.
currentAnchorId: string | null
// messageId values intersecting the viewport, in document order.
visibleMessageIds: string[]
}
// Headless provider for a chat transcript scroller. Owns scroll behavior and
// state; renders no DOM.
type MessageScrollerProviderProps = {
children?: React.ReactNode
// Follow new content at the bottom while the viewport is already at the end.
autoScroll?: boolean
// Opening position on the first non-empty render, applied once.
defaultScrollPosition?: MessageScrollerDefaultScrollPosition
// Distance from an edge that still counts as at-top/at-bottom. Defaults to 8.
scrollEdgeThreshold?: number
// Extra top margin for a newly anchored row, added to scrollMargin. Defaults to 64.
scrollPreviousItemPeek?: number
// Default margin on the aligned edge for commands and visibility. Defaults to 0.
scrollMargin?: number
}
// Frame container for a chat transcript scroller. Must render inside a
// MessageScrollerProvider.
type MessageScrollerProps = React.ComponentProps<"div">
// Scrollable viewport. Owns native scroll events and prepend preservation.
type MessageScrollerViewportProps = React.ComponentProps<"div"> & {
// Keep the first visible messageId row stable on prepend. Defaults to true.
preserveScrollOnPrepend?: boolean
}
// Transcript row container. Every direct child should be a MessageScrollerItem.
type MessageScrollerContentProps = React.ComponentProps<"div"> & {
// Class name for the internal tail spacer used when anchoring turns near the top.
spacerClassName?: string
}
// One transcript row: a message, marker, typing row, separator, or load-more row.
type MessageScrollerItemProps = React.ComponentProps<"div"> & {
// Stable row id for scrollToMessage, visibility, and prepend preservation.
messageId?: string
// Marks a turn boundary that newly appended anchors and last-anchor restore use.
scrollAnchor?: boolean
}
// State passed to MessageScrollerButton's render prop.
type MessageScrollerButtonRenderState = {
// Whether overflow exists toward this button's direction.
active: boolean
direction: MessageScrollerButtonDirection
}
// Scroll control for the start or end of the transcript.
type MessageScrollerButtonProps = UseRenderComponentProps<
"button",
MessageScrollerButtonRenderState
> & {
// Native scroll behavior when clicked. Defaults to "smooth".
behavior?: ScrollBehavior
// Transcript edge to scroll toward. Defaults to "end".
direction?: MessageScrollerButtonDirection
}
// Minimal external store backing useMessageScrollerScrollable, consumed via
// useSyncExternalStore.
type MessageScrollerStore<T> = {
getSnapshot: () => T
setSnapshot: (nextSnapshot: T) => void
subscribe: (listener: () => void) => () => void
}
// External store backing useMessageScrollerVisibility. Adds reference-counting so
// tracking stays lazy.
type MessageScrollerVisibilityStore = {
getSnapshot: () => MessageScrollerVisibilityState
hasListeners: () => boolean
setSnapshot: (nextSnapshot: MessageScrollerVisibilityState) => void
subscribe: (
listener: () => void,
onFirstSubscribe: () => void,
onLastUnsubscribe: () => void
) => () => void
}
// Registers (or, with removedElement, unregisters) a MessageScrollerItem node by
// messageId.
type MessageScrollerRegisterMessage = (
messageId: string,
element: HTMLElement | null,
removedElement?: HTMLElement | null
) => void
// Internal context wiring the parts together. Not part of the public API.
type MessageScrollerContextValue = {
handleContentChange: () => void
handleResize: () => void
observeVisibility: () => void
preserveScrollOnPrependRef: React.RefObject<boolean>
scrollToEnd: (options?: MessageScrollerScrollOptions) => boolean
scrollToMessage: (
messageId: string,
options?: MessageScrollerScrollOptions
) => boolean
scrollToStart: (options?: MessageScrollerScrollOptions) => boolean
setContentElement: (element: HTMLDivElement | null) => void
setRootElement: (element: HTMLDivElement | null) => void
setSpacerElement: (element: HTMLDivElement | null) => void
setViewportElement: (element: HTMLDivElement | null) => void
pendingDefaultScrollStore: MessageScrollerStore<boolean>
stateStore: MessageScrollerStore<MessageScrollerScrollable>
syncAfterScroll: () => void
unobserveVisibility: () => void
userScrollIntent: () => void
viewportRef: React.RefObject<HTMLDivElement | null>
visibilityStore: MessageScrollerVisibilityStore
}
// Initial MessageScrollerScrollable before measurement. Stable reference for the
// server and first-render snapshot.
const EMPTY_MESSAGE_SCROLLER_SCROLLABLE: MessageScrollerScrollable = {
start: false,
end: false,
}
// Shared empty array so empty visibility snapshots stay referentially stable.
const EMPTY_VISIBLE_MESSAGE_IDS: string[] = []
// Initial MessageScrollerVisibilityState. Nothing tracked, no current anchor.
const EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE: MessageScrollerVisibilityState =
{
currentAnchorId: null,
visibleMessageIds: EMPTY_VISIBLE_MESSAGE_IDS,
}
export {
AUTOSCROLLING_CLEAR_DELAY,
DEFAULT_SCROLL_EDGE_THRESHOLD,
DEFAULT_SCROLL_MARGIN,
DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
EMPTY_VISIBLE_MESSAGE_IDS,
SCROLL_POSITION_EPSILON,
USER_SCROLL_KEYS,
}
export type {
MessageScrollerButtonDirection,
MessageScrollerButtonProps,
MessageScrollerButtonRenderState,
MessageScrollerContentProps,
MessageScrollerContextValue,
MessageScrollerDefaultScrollPosition,
MessageScrollerItemProps,
MessageScrollerMode,
MessageScrollerProps,
MessageScrollerProviderProps,
MessageScrollerRegisterMessage,
MessageScrollerScrollAlign,
MessageScrollerScrollOptions,
MessageScrollerScrollable,
MessageScrollerStore,
MessageScrollerViewportProps,
MessageScrollerVisibilityState,
MessageScrollerVisibilityStore,
}

View File

@@ -0,0 +1,328 @@
import * as React from "react"
import {
getElementScrollTop,
getElementViewportTop,
getMaxScrollTop,
getTailSpacerHeight,
} from "./geometry"
import { AUTOSCROLLING_CLEAR_DELAY, SCROLL_POSITION_EPSILON } from "./types"
import type { MessageScrollerScrollOptions } from "./types"
import {
markDefaultScrollPositionApplied,
type MessageScrollerRefs,
} from "./use-message-scroller-refs"
// Imperative scroll primitives, split from the controller so the move mechanics
// live apart from the policy that decides when to run them. Each command resolves
// a target scrollTop and returns false when the viewport is not mounted yet.
function useMessageScrollerCommands({
refs,
commitScrollState,
scheduleStateCommit,
scheduleVisibilitySync,
}: {
refs: MessageScrollerRefs
commitScrollState: () => void
scheduleStateCommit: () => void
scheduleVisibilitySync: () => void
}) {
const {
streamingTurnRef,
autoScrollRef,
autoscrollingRef,
autoscrollingTimeoutRef,
contentRef,
itemCountRef,
messageElementsRef,
modeRef,
pendingScrollToMessageRef,
prependRestoreRef,
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
viewportRef,
} = refs
const setAutoScrolling = React.useCallback(
(autoscrolling: boolean) => {
if (autoscrollingTimeoutRef.current !== null) {
window.clearTimeout(autoscrollingTimeoutRef.current)
autoscrollingTimeoutRef.current = null
}
if (autoscrollingRef.current !== autoscrolling) {
autoscrollingRef.current = autoscrolling
commitScrollState()
}
if (autoscrolling) {
autoscrollingTimeoutRef.current = window.setTimeout(() => {
autoscrollingTimeoutRef.current = null
autoscrollingRef.current = false
commitScrollState()
}, AUTOSCROLLING_CLEAR_DELAY)
}
},
[commitScrollState]
)
const setTailSpacerHeight = React.useCallback((height: number) => {
const spacer = spacerRef.current
if (!spacer) {
return
}
const nextHeight = Math.max(0, Math.ceil(height))
if (spacerHeightRef.current === nextHeight) {
return
}
spacerHeightRef.current = nextHeight
spacer.hidden = nextHeight === 0
spacer.style.height = `${nextHeight}px`
spacer.style.marginTop = nextHeight > 0 ? `${-spacerGapRef.current}px` : ""
}, [])
const scrollToPosition = React.useCallback(
(
scrollTop: number,
{
behavior = "auto",
autoscrolling = false,
}: {
behavior?: ScrollBehavior
autoscrolling?: boolean
} = {}
) => {
const viewport = viewportRef.current
if (!viewport) {
return
}
const nextScrollTop = Math.max(0, scrollTop)
if (
Math.abs(viewport.scrollTop - nextScrollTop) <= SCROLL_POSITION_EPSILON
) {
viewport.scrollTop = nextScrollTop
commitScrollState()
return
}
if (autoscrolling) {
setAutoScrolling(true)
}
viewport.scrollTo({
top: nextScrollTop,
behavior,
})
scheduleStateCommit()
},
[commitScrollState, scheduleStateCommit, setAutoScrolling]
)
const scrollToStart = React.useCallback(
({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => {
if (!viewportRef.current) {
return false
}
setTailSpacerHeight(0)
streamingTurnRef.current = null
modeRef.current = "free-scrolling"
scrollToPosition(0, { behavior })
scheduleVisibilitySync()
return true
},
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
)
const scrollToEnd = React.useCallback(
({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => {
const viewport = viewportRef.current
if (!viewport) {
return false
}
setTailSpacerHeight(0)
streamingTurnRef.current = null
modeRef.current = autoScrollRef.current
? "following-bottom"
: "free-scrolling"
scrollToPosition(getMaxScrollTop(viewport), {
autoscrolling: true,
behavior,
})
scheduleVisibilitySync()
return true
},
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
)
const scrollToElement = React.useCallback(
(
element: HTMLElement,
{
align = "start",
behavior = "auto",
scrollMargin = scrollMarginRef.current,
}: MessageScrollerScrollOptions = {},
{
keepPreviousPeek = false,
}: {
keepPreviousPeek?: boolean
} = {}
) => {
const content = contentRef.current
const viewport = viewportRef.current
if (!content || !viewport || !content.contains(element)) {
return false
}
const scrollTop = getElementScrollTop({
align,
element,
scrollMargin: keepPreviousPeek
? scrollMargin + scrollPreviousItemPeekRef.current
: scrollMargin,
spacer: spacerRef.current,
viewport,
})
const nextSpacerHeight = getTailSpacerHeight({
content,
scrollTop,
spacer: spacerRef.current,
viewport,
})
setTailSpacerHeight(nextSpacerHeight)
// Seed the prepend anchor with the jump target so a prepend that lands
// before this scroll settles still preserves the jumped-to row; once it
// settles, syncAfterScroll's capturePrependAnchor re-captures it from the
// first visible row.
prependRestoreRef.current = {
element,
viewportTop: getElementViewportTop(element, viewport),
}
modeRef.current = keepPreviousPeek
? "anchored-to-message"
: "settling-jump"
streamingTurnRef.current = keepPreviousPeek ? element : null
scrollToPosition(scrollTop, { behavior })
scheduleVisibilitySync()
return true
},
[scheduleVisibilitySync, scrollToPosition, setTailSpacerHeight]
)
const reanchorToAnchoredMessage = React.useCallback(() => {
const element = streamingTurnRef.current
if (
!element ||
!element.isConnected ||
modeRef.current !== "anchored-to-message"
) {
return false
}
// Re-run the placement so the tail spacer is recomputed for the new content
// height and the turn is held at the reading line.
return scrollToElement(
element,
{ align: "start" },
{ keepPreviousPeek: true }
)
}, [scrollToElement])
// The target row may not be mounted yet (e.g. an async-loaded transcript).
// When it is missing the request is queued in pendingScrollToMessageRef and
// flushed later — on registerMessage for that id, or on the next content
// change. An explicit jump also marks the mount default as applied, so
// defaultScrollPosition does not override it.
const scrollToMessage = React.useCallback(
(messageId: string, options?: MessageScrollerScrollOptions) => {
const element = messageElementsRef.current.get(messageId)
if (!element) {
if (itemCountRef.current === 0) {
pendingScrollToMessageRef.current = {
messageId,
options,
}
markDefaultScrollPositionApplied(refs)
return true
}
return false
}
markDefaultScrollPositionApplied(refs)
if (scrollToElement(element, options)) {
pendingScrollToMessageRef.current = null
return true
}
pendingScrollToMessageRef.current = {
messageId,
options,
}
return true
},
[scrollToElement]
)
const flushPendingScrollToMessage = React.useCallback(() => {
const pending = pendingScrollToMessageRef.current
if (!pending) {
return false
}
const element = messageElementsRef.current.get(pending.messageId)
if (!element) {
return false
}
const handled = scrollToElement(element, pending.options)
if (!handled) {
return false
}
pendingScrollToMessageRef.current = null
markDefaultScrollPositionApplied(refs)
return true
}, [scrollToElement])
return {
flushPendingScrollToMessage,
reanchorToAnchoredMessage,
scrollToElement,
scrollToEnd,
scrollToMessage,
scrollToStart,
}
}
export { useMessageScrollerCommands }

View File

@@ -0,0 +1,774 @@
import * as React from "react"
import {
getContentBottom,
getElementTop,
getElementViewportTop,
getFirstVisibleMessageItem,
getFlexGap,
getLastScrollAnchor,
getMessageScrollerItems,
getMessageScrollerScrollable,
getMessageScrollerVisibilityState,
getNewScrollAnchor,
getUnanchoredScrollAnchor,
hasMultipleNewScrollAnchors,
} from "./geometry"
import {
DEFAULT_SCROLL_EDGE_THRESHOLD,
DEFAULT_SCROLL_MARGIN,
DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
SCROLL_POSITION_EPSILON,
} from "./types"
import type {
MessageScrollerContextValue,
MessageScrollerProviderProps,
MessageScrollerRegisterMessage,
MessageScrollerScrollable,
} from "./types"
import { useMessageScrollerCommands } from "./use-message-scroller-commands"
import {
clearPendingDefaultScroll,
markDefaultScrollPositionApplied,
useMessageScrollerRefs,
} from "./use-message-scroller-refs"
// Builds a ref callback that stores the node and runs onMount once it attaches.
function useElementRef(
elementRef: React.RefObject<HTMLDivElement | null>,
onMount: () => void
) {
return React.useCallback(
(element: HTMLDivElement | null) => {
elementRef.current = element
if (element) {
onMount()
}
},
[elementRef, onMount]
)
}
// Orchestrator hook. Decides when to scroll and delegates the moves to
// useMessageScrollerCommands; state and visibility commits are coalesced on a
// requestAnimationFrame and torn down on cleanup for StrictMode safety.
function useMessageScrollerController({
autoScroll = false,
defaultScrollPosition = "end",
scrollEdgeThreshold = DEFAULT_SCROLL_EDGE_THRESHOLD,
scrollPreviousItemPeek = DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK,
scrollMargin = DEFAULT_SCROLL_MARGIN,
}: MessageScrollerProviderProps) {
const refs = useMessageScrollerRefs({
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollMargin,
scrollPreviousItemPeek,
})
const {
streamingTurnRef,
autoScrollRef,
autoscrollingRef,
autoscrollingTimeoutRef,
contentRef,
defaultScrollPositionAppliedRef,
firstItemRef,
itemCountRef,
lastScrollTopRef,
messageElementsRef,
modeRef,
pendingScrollFrameRef,
pendingScrollToMessageRef,
prependRestoreRef,
preserveScrollOnPrependRef,
pendingDefaultScrollStore,
rootRef,
scrollEdgeThresholdRef,
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
stateFrameRef,
stateStore,
viewportRef,
visibilityFrameRef,
visibilityObserverRef,
visibilityStore,
visibleMessageIdsRef,
handledScrollAnchorsRef,
} = refs
const previousDefaultScrollPositionRef = React.useRef(defaultScrollPosition)
if (previousDefaultScrollPositionRef.current !== defaultScrollPosition) {
previousDefaultScrollPositionRef.current = defaultScrollPosition
defaultScrollPositionAppliedRef.current = false
// The pending-scroll hold is mount-only. A live prop change re-applies the
// opening position in layout; it does not hide the viewport again.
}
const writeStateAttributes = React.useCallback(
(state: MessageScrollerScrollable) => {
const root = rootRef.current
const viewport = viewportRef.current
const scrollable = [state.start && "start", state.end && "end"]
.filter(Boolean)
.join(" ")
const autoScrolling = autoscrollingRef.current
for (const element of [root, viewport]) {
if (!element) {
continue
}
if (scrollable) {
element.setAttribute("data-scrollable", scrollable)
} else {
element.removeAttribute("data-scrollable")
}
element.toggleAttribute("data-autoscrolling", autoScrolling)
}
},
[]
)
// Owns the one follow-bottom transition: arm at the bottom, release on any
// scroll away (including a scrollbar drag), suppressed during a programmatic
// scroll so the auto-scroll animation cannot release itself. Arming also
// skips the anchored-to-message hold: the tail spacer makes a freshly
// anchored turn read as "at the end", and re-arming there would let the
// first streamed chunk yank the reader off the anchor. The hold hands back
// to following in handleResize, once the reply consumes the tail spacer.
const reconcileFollowMode = React.useCallback(
(scrollable: MessageScrollerScrollable) => {
const scrollTop = viewportRef.current?.scrollTop ?? 0
// Content growing past the live edge also reads as "not at the end", but
// only a scrollbar drag moves scrollTop up. Growth must not release
// follow-output: the resize handler is coalesced onto a frame, so a state
// commit can observe the grown content before follow catches up.
const scrolledUp =
scrollTop < lastScrollTopRef.current - SCROLL_POSITION_EPSILON
lastScrollTopRef.current = scrollTop
if (
autoScrollRef.current &&
!scrollable.end &&
modeRef.current !== "settling-jump" &&
modeRef.current !== "anchored-to-message"
) {
modeRef.current = "following-bottom"
} else if (
modeRef.current === "following-bottom" &&
scrollable.end &&
scrolledUp &&
!autoscrollingRef.current
) {
modeRef.current = "free-scrolling"
}
},
[]
)
const commitScrollState = React.useCallback(() => {
const nextState = getMessageScrollerScrollable({
content: contentRef.current,
scrollEdgeThreshold: scrollEdgeThresholdRef.current,
spacer: spacerRef.current,
viewport: viewportRef.current,
})
reconcileFollowMode(nextState)
// While follow-output is engaged the scroller is already closing any gap a
// streamed chunk just opened, so publishing it as scrollable toward the
// end would strobe the scroll button once per chunk. Reconcile runs on the
// raw geometry first, so a commit that releases follow still publishes the
// gap it released over.
const publishedState =
modeRef.current === "following-bottom"
? { ...nextState, end: false }
: nextState
writeStateAttributes(publishedState)
stateStore.setSnapshot(publishedState)
}, [reconcileFollowMode, stateStore, writeStateAttributes])
const scheduleStateCommit = React.useCallback(() => {
if (stateFrameRef.current !== null) {
return
}
stateFrameRef.current = window.requestAnimationFrame(() => {
stateFrameRef.current = null
commitScrollState()
})
}, [commitScrollState])
const scheduleVisibilitySync = React.useCallback(() => {
if (!visibilityStore.hasListeners()) {
return
}
if (visibilityFrameRef.current !== null) {
return
}
visibilityFrameRef.current = window.requestAnimationFrame(() => {
visibilityFrameRef.current = null
// A frame can outlive the last unsubscribe. Recomputing here would
// overwrite the EMPTY snapshot that teardown just wrote, leaving a stale
// value for the next subscriber to read.
if (!visibilityStore.hasListeners()) {
return
}
visibilityStore.setSnapshot(
getMessageScrollerVisibilityState({
content: contentRef.current,
scrollMargin: scrollMarginRef.current,
scrollPreviousItemPeek: scrollPreviousItemPeekRef.current,
spacer: spacerRef.current,
viewport: viewportRef.current,
visibleMessageIds: visibleMessageIdsRef.current,
})
)
})
}, [visibilityStore])
const {
flushPendingScrollToMessage,
reanchorToAnchoredMessage,
scrollToElement,
scrollToEnd,
scrollToMessage,
scrollToStart,
} = useMessageScrollerCommands({
refs,
commitScrollState,
scheduleStateCommit,
scheduleVisibilitySync,
})
const restorePrependedAnchor = React.useCallback(() => {
const anchor = prependRestoreRef.current
const viewport = viewportRef.current
if (!anchor || !viewport || !anchor.element.isConnected) {
return false
}
// Compare the anchor relative to the viewport, not to the content. Native
// scroll anchoring leaves the viewport-relative position unchanged, so this
// is a no-op where the browser already handled the prepend and only corrects
// the scroll where it did not (e.g. Safari) — without trusting a capability
// flag, which some engines report incorrectly.
const nextViewportTop = getElementViewportTop(anchor.element, viewport)
const delta = nextViewportTop - anchor.viewportTop
if (Math.abs(delta) <= SCROLL_POSITION_EPSILON) {
return false
}
viewport.scrollTop += delta
anchor.viewportTop = getElementViewportTop(anchor.element, viewport)
scheduleStateCommit()
scheduleVisibilitySync()
return true
}, [scheduleStateCommit, scheduleVisibilitySync])
const capturePrependAnchor = React.useCallback(() => {
const content = contentRef.current
const viewport = viewportRef.current
if (!content || !viewport) {
prependRestoreRef.current = null
return
}
const anchor = getFirstVisibleMessageItem({
content,
spacer: spacerRef.current,
viewport,
})
prependRestoreRef.current = anchor
? {
element: anchor,
viewportTop: getElementViewportTop(anchor, viewport),
}
: null
}, [])
const schedulePendingScrollToMessageFlush = React.useCallback(() => {
if (pendingScrollFrameRef.current !== null) {
return
}
pendingScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingScrollFrameRef.current = null
if (flushPendingScrollToMessage()) {
capturePrependAnchor()
}
})
}, [capturePrependAnchor, flushPendingScrollToMessage])
const applyDefaultScrollPosition = React.useCallback(() => {
if (
!defaultScrollPosition ||
defaultScrollPositionAppliedRef.current ||
itemCountRef.current === 0
) {
return false
}
let handled = false
if (defaultScrollPosition === "last-anchor") {
const content = contentRef.current
const viewport = viewportRef.current
const anchor =
content && viewport
? getLastScrollAnchor(
getMessageScrollerItems(content, spacerRef.current)
)
: null
if (!content || !viewport || !anchor) {
handled = scrollToEnd({ behavior: "auto" })
} else {
const anchorTop = getElementTop(anchor, viewport)
const contentBottom = getContentBottom({
content,
spacer: spacerRef.current,
viewport,
})
// A short last turn already fits below the anchor, so opening at the end
// shows the whole turn without leaving a blank gap beneath it.
const lastTurnFits = contentBottom - anchorTop <= viewport.clientHeight
handled = lastTurnFits
? scrollToEnd({ behavior: "auto" })
: scrollToElement(
anchor,
{ align: "start" },
{ keepPreviousPeek: true }
)
}
} else {
handled =
defaultScrollPosition === "end"
? scrollToEnd({ behavior: "auto" })
: scrollToStart({ behavior: "auto" })
}
if (!handled) {
return false
}
markDefaultScrollPositionApplied(refs)
return true
}, [defaultScrollPosition, scrollToElement, scrollToEnd, scrollToStart])
const handleContentChange = React.useCallback(() => {
const content = contentRef.current
if (!content) {
return
}
const items = getMessageScrollerItems(content, spacerRef.current)
const previousItemCount = itemCountRef.current
const previousFirstItem = firstItemRef.current
itemCountRef.current = items.length
firstItemRef.current = items[0] ?? null
// Reconcile the scroll position with the new content. Every path re-captures
// the prepend anchor afterward, so each branch just returns.
//
// Branch order is load-bearing: first-content, prepended, appended, updated.
const reconcileScrollPosition = () => {
if (flushPendingScrollToMessage()) {
return
}
if (previousItemCount === 0) {
if (applyDefaultScrollPosition()) {
return
}
if (
items.length > 0 &&
autoScrollRef.current &&
scrollToEnd({ behavior: "auto" })
) {
return
}
commitScrollState()
scheduleVisibilitySync()
return
}
const previousFirstItemIndex = previousFirstItem
? items.indexOf(previousFirstItem)
: -1
const didPrepend =
preserveScrollOnPrependRef.current && previousFirstItemIndex > 0
if (didPrepend) {
// Prepended rows are not new appends. Restore the prior scroll position.
// The restore is a no-op where native scroll anchoring already did it.
restorePrependedAnchor()
return
}
if (items.length > previousItemCount) {
const anchor = getNewScrollAnchor(items, previousItemCount)
if (anchor) {
// While the reader is following the live end, a batch of several
// anchored turns arriving at once should keep following the end — not
// yank back to anchor the first turn of the batch. A single new anchor
// still moves to the top as usual.
if (
autoScrollRef.current &&
modeRef.current === "following-bottom" &&
hasMultipleNewScrollAnchors(items, previousItemCount)
) {
scrollToEnd({ behavior: "auto" })
return
}
scrollToElement(
anchor,
{ align: "start" },
{ keepPreviousPeek: true }
)
handledScrollAnchorsRef.current.add(anchor)
return
}
}
if (items.length === previousItemCount) {
const anchor = getUnanchoredScrollAnchor(
items,
handledScrollAnchorsRef.current
)
if (anchor) {
scrollToElement(
anchor,
{ align: "start" },
{ keepPreviousPeek: true }
)
handledScrollAnchorsRef.current.add(anchor)
return
}
}
// Appends with no new anchor (and content-only updates) fall through here:
// keep following the end if we still are, otherwise just recommit state.
if (modeRef.current === "following-bottom" && autoScrollRef.current) {
scrollToEnd({ behavior: "auto" })
} else {
commitScrollState()
scheduleVisibilitySync()
}
}
reconcileScrollPosition()
capturePrependAnchor()
}, [
applyDefaultScrollPosition,
capturePrependAnchor,
commitScrollState,
flushPendingScrollToMessage,
restorePrependedAnchor,
scheduleVisibilitySync,
scrollToElement,
scrollToEnd,
])
const handleResize = React.useCallback(() => {
if (modeRef.current === "following-bottom" && autoScrollRef.current) {
scrollToEnd({ behavior: "auto" })
return
}
// Hold the anchored turn in place as content below it resizes (a reply
// streaming in, or a transient marker collapsing) — otherwise the shrinking
// content lets the browser clamp scrollTop and the turn drops.
const previousSpacerHeight = spacerHeightRef.current
if (reanchorToAnchoredMessage()) {
// The reply streaming below the anchor consumes the tail spacer as it
// grows. Once the last of it is gone the reply has filled the viewport
// and the reader is genuinely at the live edge, so autoScroll hands off
// from the anchor hold to following the bottom. Requiring the >0 → 0
// transition keeps a turn taller than the viewport (placed with no
// spacer) held instead of yanked to the end.
if (
autoScrollRef.current &&
previousSpacerHeight > 0 &&
spacerHeightRef.current === 0
) {
scrollToEnd({ behavior: "auto" })
}
return
}
scheduleStateCommit()
scheduleVisibilitySync()
}, [
reanchorToAnchoredMessage,
scheduleStateCommit,
scheduleVisibilitySync,
scrollToEnd,
])
const observeVisibility = React.useCallback(() => {
const viewport = viewportRef.current
if (!viewport || !visibilityStore.hasListeners()) {
return
}
if (typeof IntersectionObserver === "undefined") {
scheduleVisibilitySync()
return
}
if (!visibilityObserverRef.current) {
visibilityObserverRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const messageId = (entry.target as HTMLElement).dataset.messageId
if (!messageId) {
continue
}
if (entry.isIntersecting) {
visibleMessageIdsRef.current.add(messageId)
} else {
visibleMessageIdsRef.current.delete(messageId)
}
}
scheduleVisibilitySync()
},
{
root: viewport,
// Shrink the root's top edge to the anchoring line so a previous turn
// peeking in the scrollMargin + peek band is not reported as visible,
// keeping visibleMessageIds consistent with currentAnchorId. Captured
// at observe time; a prop change rebuilds the observer on resubscribe.
rootMargin: `${-(
scrollMarginRef.current + scrollPreviousItemPeekRef.current
)}px 0px 0px 0px`,
threshold: [0, 0.01, 0.5, 1],
}
)
}
messageElementsRef.current.forEach((element) => {
visibilityObserverRef.current?.observe(element)
})
scheduleVisibilitySync()
}, [scheduleVisibilitySync, visibilityStore])
const unobserveVisibility = React.useCallback(() => {
if (visibilityFrameRef.current !== null) {
window.cancelAnimationFrame(visibilityFrameRef.current)
visibilityFrameRef.current = null
}
visibilityObserverRef.current?.disconnect()
visibilityObserverRef.current = null
visibleMessageIdsRef.current.clear()
visibilityStore.setSnapshot(EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE)
}, [visibilityStore])
const registerMessage = React.useCallback<MessageScrollerRegisterMessage>(
(messageId, element, removedElement) => {
if (element) {
messageElementsRef.current.set(messageId, element)
visibilityObserverRef.current?.observe(element)
scheduleVisibilitySync()
if (pendingScrollToMessageRef.current?.messageId === messageId) {
schedulePendingScrollToMessageFlush()
}
return
}
if (
removedElement &&
messageElementsRef.current.get(messageId) === removedElement
) {
messageElementsRef.current.delete(messageId)
visibleMessageIdsRef.current.delete(messageId)
visibilityObserverRef.current?.unobserve(removedElement)
scheduleVisibilitySync()
}
},
[schedulePendingScrollToMessageFlush, scheduleVisibilitySync]
)
const userScrollIntent = React.useCallback(() => {
if (
modeRef.current === "following-bottom" ||
modeRef.current === "anchored-to-message" ||
modeRef.current === "settling-jump"
) {
// A deliberate gesture releases auto-follow, turn-anchoring, and an in-flight
// programmatic jump so re-pinning (and re-arming) never fights the reader.
streamingTurnRef.current = null
modeRef.current = "free-scrolling"
}
}, [])
const mirrorStateAttributes = React.useCallback(
() => writeStateAttributes(stateStore.getSnapshot()),
[stateStore, writeStateAttributes]
)
const setRootElement = useElementRef(rootRef, mirrorStateAttributes)
const setViewportElement = useElementRef(viewportRef, mirrorStateAttributes)
const setContentElement = React.useCallback(
(element: HTMLDivElement | null) => {
contentRef.current = element
},
[]
)
const setSpacerElement = React.useCallback(
(element: HTMLDivElement | null) => {
spacerRef.current = element
spacerGapRef.current = getFlexGap(element?.parentElement ?? null)
},
[]
)
const syncAfterScroll = React.useCallback(() => {
commitScrollState()
scheduleVisibilitySync()
capturePrependAnchor()
}, [capturePrependAnchor, commitScrollState, scheduleVisibilitySync])
const context = React.useMemo<MessageScrollerContextValue>(
() => ({
handleContentChange,
handleResize,
observeVisibility,
pendingDefaultScrollStore,
preserveScrollOnPrependRef,
scrollToEnd,
scrollToMessage,
scrollToStart,
setContentElement,
setRootElement,
setSpacerElement,
setViewportElement,
stateStore,
syncAfterScroll,
unobserveVisibility,
userScrollIntent,
viewportRef,
visibilityStore,
}),
[
handleContentChange,
handleResize,
observeVisibility,
pendingDefaultScrollStore,
scrollToEnd,
scrollToMessage,
scrollToStart,
setContentElement,
setRootElement,
setSpacerElement,
setViewportElement,
stateStore,
syncAfterScroll,
unobserveVisibility,
userScrollIntent,
visibilityStore,
]
)
React.useLayoutEffect(() => {
if (applyDefaultScrollPosition()) {
return
}
if (itemCountRef.current === 0) {
clearPendingDefaultScroll(refs)
}
}, [applyDefaultScrollPosition])
React.useEffect(() => {
return () => {
// Reset every ref after cancelling. StrictMode replays effects on the same
// refs (unmount then remount), so a frame id left non-null here makes the
// scheduler on remount think a frame is still pending and never reschedule.
if (stateFrameRef.current !== null) {
window.cancelAnimationFrame(stateFrameRef.current)
stateFrameRef.current = null
}
if (visibilityFrameRef.current !== null) {
window.cancelAnimationFrame(visibilityFrameRef.current)
visibilityFrameRef.current = null
}
if (autoscrollingTimeoutRef.current !== null) {
window.clearTimeout(autoscrollingTimeoutRef.current)
autoscrollingTimeoutRef.current = null
}
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current)
pendingScrollFrameRef.current = null
}
visibilityObserverRef.current?.disconnect()
visibilityObserverRef.current = null
}
}, [])
React.useLayoutEffect(() => {
if (
autoScroll &&
modeRef.current === "following-bottom" &&
itemCountRef.current > 0
) {
scrollToEnd({ behavior: "auto" })
return
}
commitScrollState()
}, [autoScroll, commitScrollState, scrollToEnd])
return {
context,
registerMessage,
}
}
export { useMessageScrollerController }

View File

@@ -0,0 +1,200 @@
import * as React from "react"
import {
areScrollStatesEqual,
createMessageScrollerStore,
createMessageScrollerVisibilityStore,
} from "./stores"
import { EMPTY_MESSAGE_SCROLLER_SCROLLABLE } from "./types"
import type {
MessageScrollerDefaultScrollPosition,
MessageScrollerMode,
MessageScrollerScrollable,
MessageScrollerScrollOptions,
MessageScrollerStore,
MessageScrollerVisibilityStore,
} from "./types"
// Shared mutable ref bag for one MessageScroller, closed over by both the
// controller and the commands so writes are visible across them without prop
// threading. stateStore and visibilityStore fan out via useSyncExternalStore.
type MessageScrollerRefs = {
autoScrollRef: React.RefObject<boolean>
autoscrollingRef: React.RefObject<boolean>
autoscrollingTimeoutRef: React.RefObject<number | null>
streamingTurnRef: React.RefObject<HTMLElement | null>
contentRef: React.RefObject<HTMLDivElement | null>
defaultScrollPositionAppliedRef: React.RefObject<boolean>
firstItemRef: React.RefObject<HTMLElement | null>
itemCountRef: React.RefObject<number>
lastScrollTopRef: React.RefObject<number>
messageElementsRef: React.RefObject<Map<string, HTMLElement>>
modeRef: React.RefObject<MessageScrollerMode>
pendingScrollFrameRef: React.RefObject<number | null>
pendingScrollToMessageRef: React.RefObject<{
messageId: string
options?: MessageScrollerScrollOptions
} | null>
prependRestoreRef: React.RefObject<{
element: HTMLElement
viewportTop: number
} | null>
preserveScrollOnPrependRef: React.RefObject<boolean>
pendingDefaultScrollStore: MessageScrollerStore<boolean>
rootRef: React.RefObject<HTMLDivElement | null>
scrollEdgeThresholdRef: React.RefObject<number>
scrollMarginRef: React.RefObject<number>
scrollPreviousItemPeekRef: React.RefObject<number>
spacerGapRef: React.RefObject<number>
spacerHeightRef: React.RefObject<number>
spacerRef: React.RefObject<HTMLDivElement | null>
stateFrameRef: React.RefObject<number | null>
stateStore: MessageScrollerStore<MessageScrollerScrollable>
viewportRef: React.RefObject<HTMLDivElement | null>
visibilityFrameRef: React.RefObject<number | null>
visibilityObserverRef: React.RefObject<IntersectionObserver | null>
visibilityStore: MessageScrollerVisibilityStore
visibleMessageIdsRef: React.RefObject<Set<string>>
handledScrollAnchorsRef: React.RefObject<WeakSet<HTMLElement>>
}
// Builds the per-instance ref bag: the two external stores constructed once, and
// the latest prop values mirrored onto refs each render so callbacks stay stable.
function useMessageScrollerRefs({
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollMargin,
scrollPreviousItemPeek,
}: {
autoScroll: boolean
defaultScrollPosition: MessageScrollerDefaultScrollPosition
scrollEdgeThreshold: number
scrollMargin: number
scrollPreviousItemPeek: number
}): MessageScrollerRefs {
const autoScrollRef = React.useRef(autoScroll)
const autoscrollingRef = React.useRef(false)
const contentRef = React.useRef<HTMLDivElement | null>(null)
const defaultScrollPositionAppliedRef = React.useRef(false)
const scrollEdgeThresholdRef = React.useRef(scrollEdgeThreshold)
const itemCountRef = React.useRef(0)
const firstItemRef = React.useRef<HTMLElement | null>(null)
// The scrollTop seen by the previous state commit, so follow-release can tell
// a reader scrolling up from content growing past the live edge.
const lastScrollTopRef = React.useRef(0)
const modeRef = React.useRef<MessageScrollerMode>(
autoScroll ? "following-bottom" : "free-scrolling"
)
const messageElementsRef = React.useRef(new Map<string, HTMLElement>())
const pendingScrollToMessageRef = React.useRef<{
messageId: string
options?: MessageScrollerScrollOptions
} | null>(null)
// The row to hold steady on the next prepend: the first visible row, or a jump
// target seeded by scrollToElement. restorePrependedAnchor reads only this.
const prependRestoreRef = React.useRef<{
element: HTMLElement
viewportTop: number
} | null>(null)
// The turn held at the reading line so a reply streaming in below it can re-pin
// it instead of letting scrollTop clamp it loose.
const streamingTurnRef = React.useRef<HTMLElement | null>(null)
const scrollPreviousItemPeekRef = React.useRef(scrollPreviousItemPeek)
const preserveScrollOnPrependRef = React.useRef(true)
const rootRef = React.useRef<HTMLDivElement | null>(null)
const scrollMarginRef = React.useRef(scrollMargin)
const pendingScrollFrameRef = React.useRef<number | null>(null)
const spacerGapRef = React.useRef(0)
const spacerHeightRef = React.useRef(0)
const spacerRef = React.useRef<HTMLDivElement | null>(null)
const stateFrameRef = React.useRef<number | null>(null)
const pendingDefaultScrollStoreRef =
React.useRef<MessageScrollerStore<boolean> | null>(null)
const stateStoreRef =
React.useRef<MessageScrollerStore<MessageScrollerScrollable> | null>(null)
const autoscrollingTimeoutRef = React.useRef<number | null>(null)
const viewportRef = React.useRef<HTMLDivElement | null>(null)
const visibilityFrameRef = React.useRef<number | null>(null)
const visibilityObserverRef = React.useRef<IntersectionObserver | null>(null)
const visibilityStoreRef =
React.useRef<MessageScrollerVisibilityStore | null>(null)
const visibleMessageIdsRef = React.useRef(new Set<string>())
const handledScrollAnchorsRef = React.useRef(new WeakSet<HTMLElement>())
if (pendingDefaultScrollStoreRef.current === null) {
pendingDefaultScrollStoreRef.current = createMessageScrollerStore(
defaultScrollPosition === "end" ||
defaultScrollPosition === "last-anchor",
(current, next) => current === next
)
}
if (stateStoreRef.current === null) {
stateStoreRef.current = createMessageScrollerStore(
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
areScrollStatesEqual
)
}
if (visibilityStoreRef.current === null) {
visibilityStoreRef.current = createMessageScrollerVisibilityStore()
}
// Track the latest prop values on every render so callbacks read fresh values
// without being recreated (the useLatest pattern).
autoScrollRef.current = autoScroll
scrollEdgeThresholdRef.current = scrollEdgeThreshold
scrollMarginRef.current = scrollMargin
scrollPreviousItemPeekRef.current = scrollPreviousItemPeek
return {
autoScrollRef,
autoscrollingRef,
autoscrollingTimeoutRef,
streamingTurnRef,
contentRef,
defaultScrollPositionAppliedRef,
firstItemRef,
itemCountRef,
lastScrollTopRef,
messageElementsRef,
modeRef,
pendingScrollFrameRef,
pendingScrollToMessageRef,
prependRestoreRef,
preserveScrollOnPrependRef,
pendingDefaultScrollStore: pendingDefaultScrollStoreRef.current,
rootRef,
scrollEdgeThresholdRef,
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
stateFrameRef,
stateStore: stateStoreRef.current,
viewportRef,
visibilityFrameRef,
visibilityObserverRef,
visibilityStore: visibilityStoreRef.current,
visibleMessageIdsRef,
handledScrollAnchorsRef,
}
}
function clearPendingDefaultScroll(refs: MessageScrollerRefs) {
refs.pendingDefaultScrollStore.setSnapshot(false)
}
function markDefaultScrollPositionApplied(refs: MessageScrollerRefs) {
refs.defaultScrollPositionAppliedRef.current = true
clearPendingDefaultScroll(refs)
}
export {
clearPendingDefaultScroll,
markDefaultScrollPositionApplied,
useMessageScrollerRefs,
}
export type { MessageScrollerRefs }

View File

@@ -0,0 +1,11 @@
import * as React from "react"
function useLatest<T>(value: T) {
const ref = React.useRef(value)
ref.current = value
return ref
}
export { useLatest }

View File

@@ -0,0 +1,98 @@
# Questionnaire
An unstyled, multi-step questionnaire for React. `Root` renders a native form,
and answers are read with `FormData`.
Questionnaire supports server rendering. Pass `Root.items` to include complete
collection-derived state in the initial HTML.
## Usage
```tsx
const items = [
{
name: "prototype",
required: true,
prompt: "What should we prototype next?",
description: "Choose a direction or write your own.",
choices: [
{
value: "delegation",
label: "Delegation",
description: "Show how work moves to a specialist.",
},
{ value: "questions", label: "Question prompts" },
],
input: { label: "Another answer", placeholder: "Type another answer…" },
},
{
name: "detail",
required: false,
prompt: "How much detail?",
description: "Skip this if you are not sure yet.",
choices: [
{ value: "focused", label: "Focused" },
{ value: "complete", label: "Complete flow" },
],
},
] as const
```
```tsx
import { Questionnaire } from "@shadcn/react/questionnaire"
export function ProjectQuestionnaire() {
return (
<Questionnaire.Root
items={items}
onSubmit={(event) => {
event.preventDefault()
const answers = new FormData(event.currentTarget)
}}
>
<Questionnaire.Progress />
{items.map((question) => (
<Questionnaire.Item
key={question.name}
name={question.name}
required={question.required}
>
<Questionnaire.Title>{question.prompt}</Questionnaire.Title>
<Questionnaire.Description>
{question.description}
</Questionnaire.Description>
<Questionnaire.Choices>
{question.choices.map((choice) => (
<Questionnaire.Choice key={choice.value} value={choice.value}>
<Questionnaire.ChoiceInput />
<Questionnaire.ChoiceLabel>
<span>{choice.label}</span>
{"description" in choice ? (
<span>{choice.description}</span>
) : null}
</Questionnaire.ChoiceLabel>
<Questionnaire.ChoiceShortcut />
</Questionnaire.Choice>
))}
{"input" in question ? (
<Questionnaire.Input
aria-label={question.input.label}
placeholder={question.input.placeholder}
/>
) : null}
</Questionnaire.Choices>
<Questionnaire.Error />
</Questionnaire.Item>
))}
<Questionnaire.Previous />
<Questionnaire.Skip />
<Questionnaire.Next />
<Questionnaire.Submit />
</Questionnaire.Root>
)
}
```
## Documentation
Read the full docs at [ui.shadcn.com/docs/react/questionnaire](https://ui.shadcn.com/docs/react/questionnaire).

View File

@@ -0,0 +1,234 @@
import type {
ItemRegistration,
QuestionnaireItemDefinition,
QuestionnaireShortcutMode,
} from "./types"
import { getShortcutKeys } from "./utils"
type QuestionnaireCollection = {
enabledItems: readonly QuestionnaireItemDefinition[]
itemByName: ReadonlyMap<string, QuestionnaireItemDefinition>
items: readonly QuestionnaireItemDefinition[]
}
function createQuestionnaireCollection(
items: readonly QuestionnaireItemDefinition[] | undefined
): QuestionnaireCollection | null {
if (items === undefined) {
return null
}
return {
enabledItems: items.filter((item) => !item.disabled),
itemByName: new Map(items.map((item) => [item.name, item])),
items,
}
}
function getInitialItemName(
collection: QuestionnaireCollection | null,
defaultItem: string | undefined
) {
if (!collection) {
return defaultItem ?? null
}
const defaultDefinition = defaultItem
? collection.itemByName.get(defaultItem)
: undefined
if (defaultDefinition && !defaultDefinition.disabled) {
return defaultDefinition.name
}
return collection.enabledItems[0]?.name ?? null
}
function getShortcutByChoiceValue(
item: QuestionnaireItemDefinition | undefined,
shortcuts: QuestionnaireShortcutMode | null
) {
const shortcutByChoiceValue = new Map<string, string>()
if (!item || !shortcuts) {
return shortcutByChoiceValue
}
const keys = getShortcutKeys(shortcuts)
let shortcutIndex = 0
for (const choice of item.choices ?? []) {
if (choice.disabled) {
continue
}
const shortcut = keys[shortcutIndex]
if (!shortcut) {
break
}
shortcutByChoiceValue.set(choice.value, shortcut)
shortcutIndex += 1
}
return shortcutByChoiceValue
}
function getCollectionDefinitionWarnings(
collection: QuestionnaireCollection,
defaultItem: string | undefined
) {
const warnings: string[] = []
const itemNames = new Set<string>()
for (const item of collection.items) {
if (itemNames.has(item.name)) {
warnings.push(`Item name "${item.name}" is defined more than once.`)
}
itemNames.add(item.name)
const choiceValues = new Set<string>()
for (const choice of item.choices ?? []) {
if (choiceValues.has(choice.value)) {
warnings.push(
`Choice value "${choice.value}" is defined more than once in item "${item.name}".`
)
}
choiceValues.add(choice.value)
}
}
if (defaultItem) {
const defaultDefinition = collection.itemByName.get(defaultItem)
if (!defaultDefinition || defaultDefinition.disabled) {
warnings.push(
`defaultItem "${defaultItem}" does not identify an enabled item. The first enabled item will be used instead.`
)
}
}
return warnings
}
function getCollectionRegistrationWarnings(
collection: QuestionnaireCollection,
registrations: readonly ItemRegistration[],
shortcuts: QuestionnaireShortcutMode | null
) {
const warnings: string[] = []
const registrationByName = new Map(
registrations.map((registration) => [registration.name, registration])
)
for (const definition of collection.items) {
const registration = registrationByName.get(definition.name)
if (!registration) {
if (!definition.disabled) {
warnings.push(
`Item "${definition.name}" is defined but has no rendered Questionnaire.Item.`
)
}
continue
}
if (registration.disabled !== Boolean(definition.disabled)) {
warnings.push(
`Item "${definition.name}" has different disabled values in Root.items and Questionnaire.Item.`
)
}
if (registration.required !== Boolean(definition.required)) {
warnings.push(
`Item "${definition.name}" has different required values in Root.items and Questionnaire.Item.`
)
}
const definedChoices = definition.choices ?? []
const definedChoiceByValue = new Map(
definedChoices.map((choice) => [choice.value, choice])
)
const registeredChoiceByValue = new Map(
registration.choices.map((choice) => [choice.value, choice])
)
for (const choice of definedChoices) {
const registeredChoice = registeredChoiceByValue.get(choice.value)
if (!registeredChoice) {
warnings.push(
`Choice "${choice.value}" is defined for item "${definition.name}" but has no rendered Questionnaire.Choice.`
)
continue
}
if (registeredChoice.disabled !== Boolean(choice.disabled)) {
warnings.push(
`Choice "${choice.value}" in item "${definition.name}" has different disabled values in Root.items and Questionnaire.Choice.`
)
}
}
if (shortcuts) {
for (const choice of registration.choices) {
if (!definedChoiceByValue.has(choice.value)) {
warnings.push(
`Rendered choice "${choice.value}" in item "${definition.name}" is missing from Root.items and will not receive a shortcut.`
)
}
}
const definedOrder = definedChoices
.filter((choice) => !choice.disabled)
.map((choice) => choice.value)
const registeredOrder = registration.choices
.filter((choice) => !choice.disabled)
.map((choice) => choice.value)
const sameChoices =
definedOrder.length > 0 &&
definedOrder.length === registeredOrder.length &&
definedOrder.every((choiceValue) =>
registeredOrder.includes(choiceValue)
)
if (
sameChoices &&
definedOrder.some(
(choiceValue, index) => choiceValue !== registeredOrder[index]
)
) {
warnings.push(
`Choice order for item "${definition.name}" differs between Root.items and the rendered Questionnaire.Choice elements.`
)
}
}
}
for (const registration of registrations) {
if (
!registration.disabled &&
!collection.itemByName.has(registration.name)
) {
warnings.push(
`Rendered item "${registration.name}" is missing from Root.items and is excluded from the questionnaire collection.`
)
}
}
return warnings
}
export {
createQuestionnaireCollection,
getCollectionDefinitionWarnings,
getCollectionRegistrationWarnings,
getInitialItemName,
getShortcutByChoiceValue,
}

View File

@@ -0,0 +1,551 @@
import * as React from "react"
import { mergeProps, useRender } from "../use-render"
import {
QuestionnaireChoiceContext,
QuestionnaireContext,
QuestionnaireItemContext,
useQuestionnaireChoiceContext,
useQuestionnaireContext,
useQuestionnaireItemContext,
} from "./context"
import type {
QuestionnaireChoiceInputProps,
QuestionnaireChoiceLabelProps,
QuestionnaireChoiceProps,
QuestionnaireChoiceShortcutProps,
QuestionnaireChoicesProps,
QuestionnaireDescriptionProps,
QuestionnaireErrorProps,
QuestionnaireInputProps,
QuestionnaireItemProps,
QuestionnaireNavigationState,
QuestionnaireNextProps,
QuestionnairePreviousProps,
QuestionnaireProgressProps,
QuestionnaireRootProps,
QuestionnaireSkipProps,
QuestionnaireSubmitProps,
QuestionnaireTitleProps,
} from "./types"
import { useQuestionnaireChoice } from "./use-questionnaire-choice"
import { useQuestionnaireInput } from "./use-questionnaire-input"
import { useQuestionnaireItem } from "./use-questionnaire-item"
import { useQuestionnaireRoot } from "./use-questionnaire-root"
function QuestionnaireRoot({
defaultItem,
item,
items,
noValidate = true,
onItemChange,
onReset,
onSubmit,
ref,
shortcuts,
...props
}: QuestionnaireRootProps) {
const { context, rootProps, state } = useQuestionnaireRoot({
defaultItem,
item,
items,
noValidate,
onItemChange,
onReset,
onSubmit,
ref,
shortcuts,
})
const element = useRender({
defaultTagName: "form",
props: mergeProps<"form">({ ...rootProps, noValidate }, props),
state,
})
return (
<QuestionnaireContext.Provider value={context}>
{element}
</QuestionnaireContext.Provider>
)
}
function QuestionnaireProgress({
children,
render,
...props
}: QuestionnaireProgressProps) {
const { current, first, last, total } = useQuestionnaireContext(
"Questionnaire.Progress"
)
const label = total ? `Question ${current} of ${total}` : undefined
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
"aria-label": "Questionnaire progress",
"aria-live": "polite",
"aria-valuemax": total || undefined,
"aria-valuemin": total ? 1 : undefined,
"aria-valuenow": total ? current : undefined,
"aria-valuetext": label,
children: children ?? label,
role: "progressbar",
},
props
),
render,
state: { current, first, last, total },
})
}
function QuestionnaireItem({
"aria-describedby": ariaDescribedBy,
"aria-keyshortcuts": ariaKeyShortcuts,
children,
disabled = false,
invalid = false,
multiple = false,
name,
onStatusChange,
ref,
required = false,
...props
}: QuestionnaireItemProps) {
const { context, itemProps, state } = useQuestionnaireItem({
"aria-describedby": ariaDescribedBy,
"aria-keyshortcuts": ariaKeyShortcuts,
disabled,
invalid,
multiple,
name,
onStatusChange,
ref,
required,
})
const element = useRender({
defaultTagName: "fieldset",
props: mergeProps<"fieldset">({ ...itemProps, children }, props),
state,
stateAttributesMapping: {
active: (isActive) => ({
"data-active": isActive ? "" : undefined,
}),
},
})
return (
<QuestionnaireItemContext.Provider value={context}>
{element}
</QuestionnaireItemContext.Provider>
)
}
function QuestionnaireTitle({ render, ...props }: QuestionnaireTitleProps) {
useQuestionnaireItemContext("Questionnaire.Title")
return useRender({
defaultTagName: "legend",
props,
render,
})
}
function QuestionnaireDescription({
id,
render,
...props
}: QuestionnaireDescriptionProps) {
const { registerDescription } = useQuestionnaireItemContext(
"Questionnaire.Description"
)
const generatedId = React.useId()
const descriptionId = id ?? generatedId
React.useLayoutEffect(
() => registerDescription(descriptionId),
[descriptionId, registerDescription]
)
return useRender({
defaultTagName: "p",
props: mergeProps<"p">({ id: descriptionId }, props),
render,
})
}
function QuestionnaireChoices({ render, ...props }: QuestionnaireChoicesProps) {
const { shortcuts } = useQuestionnaireItemContext("Questionnaire.Choices")
return useRender({
defaultTagName: "div",
props,
render,
state: { shortcuts },
})
}
function QuestionnaireChoice({
checked,
children,
defaultChecked = false,
disabled = false,
onChange,
render,
value,
...props
}: QuestionnaireChoiceProps) {
const { inputProps, state } = useQuestionnaireChoice({
checked,
defaultChecked,
disabled,
onChange,
value,
})
const element = useRender({
defaultTagName: "label",
props: mergeProps<"label">({ children }, props),
render,
state,
stateAttributesMapping: {
checked: (isChecked) => ({
"data-checked": isChecked ? "" : undefined,
"data-unchecked": isChecked ? undefined : "",
}),
},
})
return (
<QuestionnaireChoiceContext.Provider value={{ inputProps, state }}>
{element}
</QuestionnaireChoiceContext.Provider>
)
}
function QuestionnaireChoiceInput({
render,
...props
}: QuestionnaireChoiceInputProps) {
const { inputProps, state } = useQuestionnaireChoiceContext(
"Questionnaire.ChoiceInput"
)
return useRender({
defaultTagName: "input",
props: mergeProps<"input">(inputProps, props),
render,
state,
stateAttributesMapping: {
checked: (isChecked) => ({
"data-checked": isChecked ? "" : undefined,
"data-unchecked": isChecked ? undefined : "",
}),
},
})
}
function QuestionnaireChoiceLabel({
render,
...props
}: QuestionnaireChoiceLabelProps) {
useQuestionnaireChoiceContext("Questionnaire.ChoiceLabel")
return useRender({
defaultTagName: "span",
props,
render,
})
}
function QuestionnaireChoiceShortcut({
children,
render,
...props
}: QuestionnaireChoiceShortcutProps) {
const { state } = useQuestionnaireChoiceContext(
"Questionnaire.ChoiceShortcut"
)
const shortcutState = { shortcut: state.shortcut }
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
"aria-hidden": true,
children: children ?? state.shortcut,
hidden: state.shortcut === null,
},
props
),
render,
state: shortcutState,
})
}
function QuestionnaireInput({
defaultValue,
disabled = false,
onChange,
ref,
render,
type = "text",
value,
...props
}: QuestionnaireInputProps) {
const { inputProps, state } = useQuestionnaireInput({
defaultValue,
disabled,
onChange,
ref,
type,
value,
})
return useRender({
defaultTagName: "input",
props: mergeProps<"input">(inputProps, props),
render,
state,
stateAttributesMapping: {
filled: (isFilled) => ({
"data-empty": isFilled ? undefined : "",
"data-filled": isFilled ? "" : undefined,
}),
},
})
}
function QuestionnaireError({
children,
id,
render,
...props
}: QuestionnaireErrorProps) {
const { invalid, registerError, required } = useQuestionnaireItemContext(
"Questionnaire.Error"
)
const generatedId = React.useId()
const errorId = id ?? generatedId
React.useLayoutEffect(() => registerError(errorId), [errorId, registerError])
return useRender({
defaultTagName: "p",
props: mergeProps<"p">(
{
children:
children ??
(required
? "Choose an answer to continue."
: "Choose an answer or skip this question."),
hidden: !invalid,
id: errorId,
role: invalid ? "alert" : undefined,
},
props
),
render,
state: { invalid },
})
}
function QuestionnairePrevious({
children,
disabled: disabledProp = false,
onClick,
render,
tabIndex,
type = "button",
...props
}: QuestionnairePreviousProps) {
const context = useQuestionnaireContext("Questionnaire.Previous")
const visible = context.total > 1 && !context.first
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event)
if (!event.defaultPrevented) {
context.goPrevious()
}
}
return useRenderNavigationButton({
children: children ?? "Previous",
disabled: disabledProp,
onClick: handleClick,
props,
render,
status: context.activeItemStatus,
tabIndex,
type,
visible,
})
}
function QuestionnaireSkip({
children,
disabled: disabledProp = false,
onClick,
render,
tabIndex,
type = "button",
...props
}: QuestionnaireSkipProps) {
const context = useQuestionnaireContext("Questionnaire.Skip")
const visible = context.activeItemRequired === false
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event)
if (!event.defaultPrevented) {
context.skipCurrent()
}
}
return useRenderNavigationButton({
children: children ?? "Skip",
disabled: disabledProp,
onClick: handleClick,
props,
render,
status: context.activeItemStatus,
tabIndex,
type,
visible,
})
}
function QuestionnaireNext({
children,
disabled: disabledProp = false,
onClick,
render,
tabIndex,
type = "button",
...props
}: QuestionnaireNextProps) {
const context = useQuestionnaireContext("Questionnaire.Next")
const visible = context.total > 1 && !context.last
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event)
if (!event.defaultPrevented) {
context.goNext()
}
}
return useRenderNavigationButton({
children: children ?? "Next",
disabled: disabledProp,
onClick: handleClick,
props,
render,
shortcut: "Enter",
status: context.activeItemStatus,
tabIndex,
type,
visible,
})
}
function QuestionnaireSubmit({
children,
disabled: disabledProp = false,
render,
tabIndex,
type = "submit",
...props
}: QuestionnaireSubmitProps) {
const context = useQuestionnaireContext("Questionnaire.Submit")
const visible = context.total > 0 && context.last
return useRenderNavigationButton({
children: children ?? "Submit",
disabled: disabledProp,
props,
render,
shortcut: "Enter",
status: context.activeItemStatus,
tabIndex,
type,
visible,
})
}
function useRenderNavigationButton({
children,
disabled,
onClick,
props,
render,
shortcut,
status,
tabIndex,
type,
visible,
}: {
children: React.ReactNode
disabled: boolean
onClick?: React.MouseEventHandler<HTMLButtonElement>
props: React.ComponentPropsWithRef<"button">
render: QuestionnaireNextProps["render"]
shortcut?: "Enter"
status: QuestionnaireNavigationState["status"]
tabIndex: number | undefined
type: "button" | "reset" | "submit"
visible: boolean
}) {
const activeShortcut = visible && !disabled ? (shortcut ?? null) : null
const state: QuestionnaireNavigationState = {
disabled,
shortcut: activeShortcut,
status,
visible,
}
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
"aria-hidden": !visible || undefined,
"aria-keyshortcuts": activeShortcut ?? undefined,
children,
disabled,
hidden: !visible,
inert: !visible,
onClick,
tabIndex: visible ? tabIndex : -1,
type,
},
props
),
render,
state,
stateAttributesMapping: {
visible: (isVisible) => ({
"data-hidden": isVisible ? undefined : "",
"data-visible": isVisible ? "" : undefined,
}),
},
})
}
export {
QuestionnaireChoice,
QuestionnaireChoiceInput,
QuestionnaireChoiceLabel,
QuestionnaireChoiceShortcut,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireRoot,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import type {
QuestionnaireChoiceContextValue,
QuestionnaireContextValue,
QuestionnaireItemContextValue,
} from "./types"
const QuestionnaireChoiceContext =
React.createContext<QuestionnaireChoiceContextValue | null>(null)
const QuestionnaireContext =
React.createContext<QuestionnaireContextValue | null>(null)
const QuestionnaireItemContext =
React.createContext<QuestionnaireItemContextValue | null>(null)
function useQuestionnaireContext(component: string) {
const context = React.useContext(QuestionnaireContext)
if (!context) {
throw new Error(
`${component} must be used within a Questionnaire.Root component.`
)
}
return context
}
function useQuestionnaireChoiceContext(component: string) {
const context = React.useContext(QuestionnaireChoiceContext)
if (!context) {
throw new Error(
`${component} must be used within a Questionnaire.Choice component.`
)
}
return context
}
function useQuestionnaireItemContext(component: string) {
const context = React.useContext(QuestionnaireItemContext)
if (!context) {
throw new Error(
`${component} must be used within a Questionnaire.Item component.`
)
}
return context
}
export {
QuestionnaireChoiceContext,
QuestionnaireContext,
QuestionnaireItemContext,
useQuestionnaireChoiceContext,
useQuestionnaireContext,
useQuestionnaireItemContext,
}

View File

@@ -0,0 +1,45 @@
import {
QuestionnaireChoice as Choice,
QuestionnaireChoiceInput as ChoiceInput,
QuestionnaireChoiceLabel as ChoiceLabel,
QuestionnaireChoices as Choices,
QuestionnaireChoiceShortcut as ChoiceShortcut,
QuestionnaireDescription as Description,
QuestionnaireError as Error,
QuestionnaireInput as Input,
QuestionnaireItem as Item,
QuestionnaireNext as Next,
QuestionnairePrevious as Previous,
QuestionnaireProgress as Progress,
QuestionnaireRoot as Root,
QuestionnaireSkip as Skip,
QuestionnaireSubmit as Submit,
QuestionnaireTitle as Title,
} from "./components"
export const Questionnaire = {
Root,
Progress,
Item,
Title,
Description,
Choices,
Choice,
ChoiceInput,
ChoiceLabel,
ChoiceShortcut,
Input,
Error,
Previous,
Skip,
Next,
Submit,
}
export type {
QuestionnaireChoiceDefinition,
QuestionnaireInputType,
QuestionnaireItemDefinition,
QuestionnaireItemStatus,
QuestionnaireShortcutMode,
} from "./types"

View File

@@ -0,0 +1,724 @@
// @vitest-environment jsdom
import * as React from "react"
import { act } from "react"
import { hydrateRoot, type Root } from "react-dom/client"
import { renderToString } from "react-dom/server"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { Questionnaire } from "."
import type { QuestionnaireItemDefinition } from "./types"
const items = [
{
choices: [
{ value: "delegation" },
{ disabled: true, value: "automatic" },
{ value: "questions" },
],
name: "scope",
required: true,
},
{
choices: [{ value: "ignored" }],
disabled: true,
name: "disabled",
},
{
choices: [{ value: "focused" }, { value: "complete" }],
name: "detail",
},
] as const satisfies readonly QuestionnaireItemDefinition[]
let container: HTMLDivElement
let root: Root | null
beforeEach(() => {
;(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement("div")
document.body.appendChild(container)
root = null
})
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount()
})
}
container.remove()
vi.restoreAllMocks()
})
describe("Questionnaire server rendering", () => {
it("renders collection progress, active item, actions, and shortcuts", () => {
renderServerQuestionnaire()
expect(form().dataset.current).toBe("1")
expect(form().dataset.total).toBe("2")
expect(form().hasAttribute("data-first")).toBe(true)
expect(progress().textContent).toBe("Question 1 of 2")
expect(progress().getAttribute("aria-valuenow")).toBe("1")
expect(progress().getAttribute("aria-valuemax")).toBe("2")
expect(item("scope").hasAttribute("data-active")).toBe(true)
expect(item("scope").hidden).toBe(false)
expect(item("disabled").hidden).toBe(true)
expect(item("detail").hidden).toBe(true)
expect(action("previous").hidden).toBe(true)
expect(action("skip").hidden).toBe(true)
expect(action("next").hidden).toBe(false)
expect(action("next").dataset.status).toBe("unanswered")
expect(action("submit").hidden).toBe(true)
expect(choice("delegation").dataset.shortcut).toBe("A")
expect(choiceInput("delegation").getAttribute("aria-keyshortcuts")).toBe(
"A"
)
expect(choice("automatic").hasAttribute("data-shortcut")).toBe(false)
expect(shortcut("automatic").hidden).toBe(true)
expect(choice("questions").dataset.shortcut).toBe("B")
})
it("renders a requested optional item and its applicable actions", () => {
renderServerQuestionnaire({ defaultItem: "detail" })
expect(form().dataset.current).toBe("2")
expect(form().hasAttribute("data-last")).toBe(true)
expect(progress().textContent).toBe("Question 2 of 2")
expect(item("scope").hidden).toBe(true)
expect(item("detail").hasAttribute("data-active")).toBe(true)
expect(action("previous").hidden).toBe(false)
expect(action("skip").hidden).toBe(false)
expect(action("next").hidden).toBe(true)
expect(action("submit").hidden).toBe(false)
})
it("renders a controlled item from the collection", () => {
renderMarkup(<TestQuestionnaire item="detail" />)
expect(progress().textContent).toBe("Question 2 of 2")
expect(item("scope").hidden).toBe(true)
expect(item("detail").hasAttribute("data-active")).toBe(true)
})
it.each(["missing", "disabled"])(
"falls back from an invalid default item %s during render",
(defaultItem) => {
renderServerQuestionnaire({ defaultItem })
expect(progress().textContent).toBe("Question 1 of 2")
expect(item("scope").hasAttribute("data-active")).toBe(true)
expect(item("detail").hidden).toBe(true)
}
)
it("renders numeric shortcuts and leaves overflow choices unassigned", () => {
const choices = Array.from({ length: 10 }, (_, index) => ({
value: `choice-${index + 1}`,
}))
const numericItems = [{ choices, name: "numeric" }]
renderMarkup(
<Questionnaire.Root items={numericItems} shortcuts="numbers">
<Questionnaire.Item name="numeric">
<Questionnaire.Title>Choose a number</Questionnaire.Title>
{choices.map((choice) => (
<TestChoice key={choice.value} value={choice.value} />
))}
</Questionnaire.Item>
</Questionnaire.Root>
)
expect(choice("choice-1").dataset.shortcut).toBe("1")
expect(choice("choice-9").dataset.shortcut).toBe("9")
expect(choice("choice-10").hasAttribute("data-shortcut")).toBe(false)
})
it("renders an input-only item without fixed-choice definitions", () => {
renderMarkup(
<Questionnaire.Root items={[{ name: "input" }]}>
<Questionnaire.Progress data-testid="progress" />
<Questionnaire.Item data-testid="input" name="input">
<Questionnaire.Title>Describe the result</Questionnaire.Title>
<Questionnaire.Input
data-testid="input-control"
aria-label="Result"
/>
</Questionnaire.Item>
<Questionnaire.Submit data-testid="submit" />
</Questionnaire.Root>
)
expect(progress().textContent).toBe("Question 1 of 1")
expect(item("input").hasAttribute("data-active")).toBe(true)
expect(query<HTMLInputElement>("input-control").type).toBe("text")
expect(action("submit").hidden).toBe(false)
})
it("renders both navigation directions for a middle item", () => {
const middleItems = [
{ name: "first" },
{ name: "middle" },
{ name: "last" },
]
renderMarkup(
<Questionnaire.Root defaultItem="middle" items={middleItems}>
<Questionnaire.Progress data-testid="progress" />
{middleItems.map((definition) => (
<Questionnaire.Item
key={definition.name}
data-testid={definition.name}
name={definition.name}
>
<Questionnaire.Title>{definition.name}</Questionnaire.Title>
</Questionnaire.Item>
))}
<Questionnaire.Previous data-testid="previous" />
<Questionnaire.Next data-testid="next" />
<Questionnaire.Submit data-testid="submit" />
</Questionnaire.Root>
)
expect(progress().textContent).toBe("Question 2 of 3")
expect(action("previous").hidden).toBe(false)
expect(action("next").hidden).toBe(false)
expect(action("submit").hidden).toBe(true)
})
it("pins the current defaultChecked server-rendering limitation", () => {
const defaultItems = [{ choices: [{ value: "default" }], name: "answer" }]
renderMarkup(
<Questionnaire.Root items={defaultItems}>
<Questionnaire.Item name="answer">
<TestChoice defaultChecked value="default" />
</Questionnaire.Item>
</Questionnaire.Root>
)
expect(choiceInput("default").checked).toBe(false)
expect(choice("default").hasAttribute("data-checked")).toBe(false)
})
})
describe("Questionnaire hydration", () => {
it("hydrates without changing collection-derived output", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const onRecoverableError = vi.fn()
const questionnaire = <TestQuestionnaire />
renderMarkup(questionnaire)
const initialMarkup = {
actions: actionsMarkup(),
progress: progress().outerHTML,
root: form().outerHTML.match(/<form[^>]*>/)?.[0],
}
await act(async () => {
root = hydrateRoot(container, questionnaire, { onRecoverableError })
})
expect(onRecoverableError).not.toHaveBeenCalled()
expect(consoleWarn).not.toHaveBeenCalled()
expect(progress().outerHTML).toBe(initialMarkup.progress)
expect(actionsMarkup()).toBe(initialMarkup.actions)
expect(form().outerHTML.match(/<form[^>]*>/)?.[0]).toBe(initialMarkup.root)
})
it("warns after falling back from an invalid default item", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const questionnaire = <TestQuestionnaire defaultItem="missing" />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(progress().textContent).toBe("Question 1 of 2")
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'defaultItem "missing" does not identify an enabled item'
)
)
})
it("joins logical navigation to matching runtime items", async () => {
const questionnaire = <TestQuestionnaire />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
await act(async () => {
choiceInput("delegation").click()
})
await act(async () => {
action("next").click()
})
expect(progress().textContent).toBe("Question 2 of 2")
expect(item("scope").hidden).toBe(true)
expect(item("detail").hasAttribute("data-active")).toBe(true)
})
it("keeps definition shortcut order authoritative after hydration", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const orderedItems = [
{
choices: [{ value: "second" }, { value: "first" }],
name: "order",
},
]
const questionnaire = (
<Questionnaire.Root items={orderedItems} shortcuts="letters">
<Questionnaire.Item data-testid="order" name="order">
<TestChoice value="first" />
<TestChoice value="second" />
</Questionnaire.Item>
</Questionnaire.Root>
)
renderMarkup(questionnaire)
expect(choice("first").dataset.shortcut).toBe("B")
expect(choice("second").dataset.shortcut).toBe("A")
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
await keydown(item("order"), "A")
expect(choiceInput("second").checked).toBe(true)
expect(choiceInput("first").checked).toBe(false)
expect(choiceInput("second").getAttribute("aria-keyshortcuts")).toContain(
"A"
)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Choice order for item "order" differs between Root.items'
)
)
})
it("warns when rendered metadata differs from its definitions", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const questionnaire = (
<Questionnaire.Root
items={[
{
choices: [{ disabled: true, value: "fixed" }],
name: "answer",
required: true,
},
]}
>
<Questionnaire.Item name="answer">
<TestChoice value="fixed" />
</Questionnaire.Item>
</Questionnaire.Root>
)
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(consoleWarn).toHaveBeenCalledTimes(2)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Item "answer" has different required values in Root.items'
)
)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Choice "fixed" in item "answer" has different disabled values'
)
)
})
it("warns about duplicate item names and choice values", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const questionnaire = (
<Questionnaire.Root
items={[
{
choices: [{ value: "fixed" }, { value: "fixed" }],
name: "answer",
},
{ disabled: true, name: "answer" },
]}
>
<Questionnaire.Item name="answer">
<TestChoice value="fixed" />
</Questionnaire.Item>
</Questionnaire.Root>
)
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining('Item name "answer" is defined more than once')
)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Choice value "fixed" is defined more than once in item "answer"'
)
)
})
it("does not assign post-hydration shortcuts to omitted definitions", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const questionnaire = (
<Questionnaire.Root items={[{ name: "answer" }]} shortcuts="letters">
<Questionnaire.Item name="answer">
<TestChoice value="fixed" />
<Questionnaire.Input aria-label="Another answer" />
</Questionnaire.Item>
</Questionnaire.Root>
)
renderMarkup(questionnaire)
expect(shortcut("fixed").hidden).toBe(true)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(shortcut("fixed").hidden).toBe(true)
expect(choiceInput("fixed").hasAttribute("aria-keyshortcuts")).toBe(false)
expect(consoleWarn).toHaveBeenCalledTimes(1)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'Rendered choice "fixed" in item "answer" is missing from Root.items'
)
)
})
it("updates authoritative order and falls back when the active item is disabled", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
const onItemChange = vi.fn()
const initialItems = [
{ name: "first" },
{ name: "second" },
{ name: "third" },
]
function DynamicQuestionnaire({
definitions,
}: {
definitions: readonly QuestionnaireItemDefinition[]
}) {
return (
<Questionnaire.Root
data-testid="root"
defaultItem="second"
items={definitions}
onItemChange={onItemChange}
>
<Questionnaire.Progress data-testid="progress" />
{definitions.map((definition) => (
<Questionnaire.Item
key={definition.name}
data-testid={definition.name}
disabled={definition.disabled}
name={definition.name}
>
<Questionnaire.Title>{definition.name}</Questionnaire.Title>
</Questionnaire.Item>
))}
<Questionnaire.Previous data-testid="previous" />
<Questionnaire.Next data-testid="next" />
<Questionnaire.Submit data-testid="submit" />
</Questionnaire.Root>
)
}
const questionnaire = <DynamicQuestionnaire definitions={initialItems} />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(progress().textContent).toBe("Question 2 of 3")
const reorderedItems = [
{ name: "second" },
{ name: "first" },
{ name: "third" },
]
await act(async () => {
root?.render(<DynamicQuestionnaire definitions={reorderedItems} />)
})
expect(progress().textContent).toBe("Question 1 of 3")
expect(action("previous").hidden).toBe(true)
const disabledItems = [
{ disabled: true, name: "second" },
{ name: "first" },
{ name: "third" },
]
await act(async () => {
root?.render(<DynamicQuestionnaire definitions={disabledItems} />)
})
expect(progress().textContent).toBe("Question 1 of 2")
expect(item("first").hasAttribute("data-active")).toBe(true)
expect(onItemChange).toHaveBeenLastCalledWith("first")
expect(consoleWarn).toHaveBeenCalledTimes(1)
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining(
'defaultItem "second" does not identify an enabled item'
)
)
const expandedItems = [
{ name: "first" },
{ name: "third" },
{ name: "fourth" },
]
await act(async () => {
root?.render(<DynamicQuestionnaire definitions={expandedItems} />)
})
expect(progress().textContent).toBe("Question 1 of 3")
expect(action("next").hidden).toBe(false)
expect(item("fourth").hidden).toBe(true)
})
it("keeps a controlled item aligned with collection updates", async () => {
function ControlledQuestionnaire({ item: activeItem }: { item: string }) {
return (
<Questionnaire.Root item={activeItem} items={items}>
<Questionnaire.Progress data-testid="progress" />
<Questionnaire.Item data-testid="scope" name="scope" required>
<Questionnaire.Title>Scope</Questionnaire.Title>
<TestChoice value="delegation" />
<TestChoice disabled value="automatic" />
<TestChoice value="questions" />
</Questionnaire.Item>
<Questionnaire.Item data-testid="detail" name="detail">
<Questionnaire.Title>Detail</Questionnaire.Title>
<TestChoice value="focused" />
<TestChoice value="complete" />
</Questionnaire.Item>
</Questionnaire.Root>
)
}
const questionnaire = <ControlledQuestionnaire item="scope" />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
await act(async () => {
root?.render(<ControlledQuestionnaire item="detail" />)
})
expect(progress().textContent).toBe("Question 2 of 2")
expect(item("scope").hidden).toBe(true)
expect(item("detail").hasAttribute("data-active")).toBe(true)
})
it("reports a mismatch again after it resolves and recurs", async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {})
function WarningQuestionnaire({
includeChoice,
}: {
includeChoice: boolean
}) {
const definitions = [
{
choices: includeChoice ? [{ value: "fixed" }] : undefined,
name: "answer",
},
]
return (
<Questionnaire.Root items={definitions} shortcuts="letters">
<Questionnaire.Item name="answer">
<TestChoice value="fixed" />
</Questionnaire.Item>
</Questionnaire.Root>
)
}
const questionnaire = <WarningQuestionnaire includeChoice />
renderMarkup(questionnaire)
await act(async () => {
root = hydrateRoot(container, questionnaire)
})
expect(consoleWarn).not.toHaveBeenCalled()
await act(async () => {
root?.render(<WarningQuestionnaire includeChoice={false} />)
})
expect(consoleWarn).toHaveBeenCalledTimes(1)
await act(async () => {
root?.render(<WarningQuestionnaire includeChoice />)
})
await act(async () => {
root?.render(<WarningQuestionnaire includeChoice={false} />)
})
expect(consoleWarn).toHaveBeenCalledTimes(2)
})
})
function TestQuestionnaire({
defaultItem,
item: controlledItem,
}: {
defaultItem?: string
item?: string
}) {
return (
<Questionnaire.Root
data-testid="root"
defaultItem={defaultItem}
item={controlledItem}
items={items}
shortcuts="letters"
>
<Questionnaire.Progress data-testid="progress" />
<Questionnaire.Item data-testid="scope" name="scope" required>
<Questionnaire.Title>Choose the scope</Questionnaire.Title>
<TestChoice value="delegation" />
<TestChoice disabled value="automatic" />
<TestChoice value="questions" />
</Questionnaire.Item>
<Questionnaire.Item data-testid="disabled" disabled name="disabled">
<Questionnaire.Title>Disabled question</Questionnaire.Title>
<TestChoice value="ignored" />
</Questionnaire.Item>
<Questionnaire.Item data-testid="detail" name="detail">
<Questionnaire.Title>Choose the detail</Questionnaire.Title>
<TestChoice value="focused" />
<TestChoice value="complete" />
</Questionnaire.Item>
<Questionnaire.Previous data-testid="previous" />
<Questionnaire.Skip data-testid="skip" />
<Questionnaire.Next data-testid="next" />
<Questionnaire.Submit data-testid="submit" />
</Questionnaire.Root>
)
}
function TestChoice({
value,
...props
}: Omit<React.ComponentProps<typeof Questionnaire.Choice>, "children">) {
return (
<Questionnaire.Choice
data-testid={`choice-${value}`}
value={value}
{...props}
>
<Questionnaire.ChoiceInput data-testid={`input-${value}`} />
<Questionnaire.ChoiceLabel>{value}</Questionnaire.ChoiceLabel>
<Questionnaire.ChoiceShortcut data-testid={`shortcut-${value}`} />
</Questionnaire.Choice>
)
}
function renderServerQuestionnaire({
defaultItem,
}: {
defaultItem?: string
} = {}) {
renderMarkup(<TestQuestionnaire defaultItem={defaultItem} />)
}
function renderMarkup(element: React.ReactNode) {
container.innerHTML = renderToString(element)
}
function form() {
return query<HTMLFormElement>("root")
}
function progress() {
return query<HTMLElement>("progress")
}
function item(name: string) {
return query<HTMLFieldSetElement>(name)
}
function choice(value: string) {
return query<HTMLLabelElement>(`choice-${value}`)
}
function choiceInput(value: string) {
return query<HTMLInputElement>(`input-${value}`)
}
function shortcut(value: string) {
return query<HTMLElement>(`shortcut-${value}`)
}
function action(name: "next" | "previous" | "skip" | "submit") {
return query<HTMLButtonElement>(name)
}
function actionsMarkup() {
return ["previous", "skip", "next", "submit"]
.map(
(name) =>
action(name as "next" | "previous" | "skip" | "submit").outerHTML
)
.join("")
}
function query<ElementType extends Element>(testId: string) {
const element = container.querySelector<ElementType>(
`[data-testid="${testId}"]`
)
if (!element) {
throw new Error(`Missing test element: ${testId}`)
}
return element
}
async function keydown(element: Element, key: string) {
await act(async () => {
element.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key })
)
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,305 @@
import * as React from "react"
import type { UseRenderComponentProps } from "../use-render"
type QuestionnaireItemStatus = "unanswered" | "answered" | "skipped"
type QuestionnaireShortcutMode = "letters" | "numbers"
type QuestionnaireChoiceDefinition = {
disabled?: boolean
value: string
}
type QuestionnaireItemDefinition = {
choices?: readonly QuestionnaireChoiceDefinition[]
disabled?: boolean
name: string
required?: boolean
}
type QuestionnaireRootState = {
current: number
first: boolean
last: boolean
total: number
}
type QuestionnaireRootProps = Omit<
React.ComponentPropsWithRef<"form">,
"defaultValue" | "value"
> & {
defaultItem?: string
item?: string
items?: readonly QuestionnaireItemDefinition[]
onItemChange?: (item: string) => void
shortcuts?: QuestionnaireShortcutMode
}
type QuestionnaireProgressState = QuestionnaireRootState
type QuestionnaireProgressProps = UseRenderComponentProps<
"div",
QuestionnaireProgressState
>
type QuestionnaireItemState = {
active: boolean
disabled: boolean
invalid: boolean
multiple: boolean
required: boolean
status: QuestionnaireItemStatus
}
type QuestionnaireItemProps = Omit<
React.ComponentPropsWithRef<"fieldset">,
"name" | "value"
> & {
invalid?: boolean
name: string
multiple?: boolean
onStatusChange?: (status: QuestionnaireItemStatus) => void
required?: boolean
}
type QuestionnaireTitleProps = UseRenderComponentProps<"legend">
type QuestionnaireDescriptionProps = UseRenderComponentProps<"p">
type QuestionnaireChoicesState = {
shortcuts: QuestionnaireShortcutMode | null
}
type QuestionnaireChoicesProps = UseRenderComponentProps<
"div",
QuestionnaireChoicesState
>
type QuestionnaireErrorProps = UseRenderComponentProps<
"p",
Pick<QuestionnaireItemState, "invalid">
>
type QuestionnaireChoiceState = {
checked: boolean
disabled: boolean
invalid: boolean
shortcut: string | null
type: "checkbox" | "radio"
}
type QuestionnaireChoiceContextValue = {
inputProps: React.ComponentPropsWithRef<"input">
state: QuestionnaireChoiceState
}
type QuestionnaireChoiceProps = Omit<
UseRenderComponentProps<"label", QuestionnaireChoiceState>,
"onChange"
> & {
checked?: boolean
defaultChecked?: boolean
disabled?: boolean
onChange?: React.ChangeEventHandler<HTMLInputElement>
value: string
}
type QuestionnaireChoiceInputProps = Omit<
UseRenderComponentProps<"input", QuestionnaireChoiceState>,
| "checked"
| "defaultChecked"
| "disabled"
| "name"
| "onChange"
| "required"
| "type"
| "value"
>
type QuestionnaireChoiceLabelProps = UseRenderComponentProps<"span">
type QuestionnaireChoiceShortcutState = Pick<
QuestionnaireChoiceState,
"shortcut"
>
type QuestionnaireChoiceShortcutProps = UseRenderComponentProps<
"span",
QuestionnaireChoiceShortcutState
>
type QuestionnaireInputState = {
disabled: boolean
filled: boolean
invalid: boolean
}
type QuestionnaireInputType =
| "date"
| "datetime-local"
| "email"
| "month"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "time"
| "url"
| "week"
type QuestionnaireInputProps = Omit<
UseRenderComponentProps<"input", QuestionnaireInputState>,
"form" | "name" | "type"
> & {
type?: QuestionnaireInputType
}
type QuestionnaireNavigationState = {
disabled: boolean
shortcut: "Enter" | null
status: QuestionnaireItemStatus | null
visible: boolean
}
type QuestionnairePreviousProps = UseRenderComponentProps<
"button",
QuestionnaireNavigationState
>
type QuestionnaireSkipProps = UseRenderComponentProps<
"button",
QuestionnaireNavigationState
>
type QuestionnaireNextProps = UseRenderComponentProps<
"button",
QuestionnaireNavigationState
>
type QuestionnaireSubmitProps = UseRenderComponentProps<
"button",
QuestionnaireNavigationState
>
type AnswerControlRegistration = {
disabled: boolean
element: HTMLInputElement
id: string
} & (
| {
ownDisabled: boolean
type: "choice"
value: string
}
| {
type: "input"
}
)
type ChoiceRegistration = {
disabled: boolean
value: string
}
type ItemRegistration = {
choices: readonly ChoiceRegistration[]
disabled: boolean
element: HTMLFieldSetElement
focus: () => void
focusInvalid: () => void
getAnswerByElement: (element: Element) => AnswerControlRegistration | null
getAnswerByShortcut: (shortcut: string) => AnswerControlRegistration | null
moveAnswerFocus: (element: Element, direction: "next" | "previous") => boolean
name: string
required: boolean
reset: () => void
skip: () => void
status: QuestionnaireItemStatus
validate: () => boolean
}
type PendingFocus = {
name: string
target: "invalid" | "item"
}
type QuestionnaireContextValue = QuestionnaireRootState & {
activeItem: ItemRegistration | null
activeItemName: string | null
activeItemRequired: boolean | null
activeItemStatus: QuestionnaireItemStatus | null
domVersion: number
goNext: () => void
goPrevious: () => void
nativeValidation: boolean
itemDefinitionByName: ReadonlyMap<string, QuestionnaireItemDefinition> | null
registerItem: (registration: ItemRegistration) => () => void
shortcuts: QuestionnaireShortcutMode | null
skipCurrent: () => void
}
type QuestionnaireItemContextValue = {
active: boolean
disabled: boolean
hasInputAnswer: boolean
invalid: boolean
multiple: boolean
name: string
registerAnswerControl: (registration: AnswerControlRegistration) => () => void
registerAnswerSelection: (
answerId: string,
defaultSelected: boolean
) => () => void
registerDescription: (descriptionId: string) => () => void
registerError: (errorId: string) => () => void
required: boolean
resetVersion: number
selectedAnswerIds: string[]
setAnswerDefault: (answerId: string, defaultSelected: boolean) => void
setAnswerSelectionFromInteraction: (
answerId: string,
selected: boolean
) => void
shortcutByAnswerId: ReadonlyMap<string, string>
shortcutByChoiceValue: ReadonlyMap<string, string> | null
shortcuts: QuestionnaireShortcutMode | null
status: QuestionnaireItemStatus
syncControlledAnswerSelection: (answerId: string, selected: boolean) => void
}
export type {
AnswerControlRegistration,
ChoiceRegistration,
ItemRegistration,
PendingFocus,
QuestionnaireChoiceContextValue,
QuestionnaireChoiceDefinition,
QuestionnaireChoiceInputProps,
QuestionnaireChoiceLabelProps,
QuestionnaireChoiceProps,
QuestionnaireChoiceShortcutProps,
QuestionnaireChoiceShortcutState,
QuestionnaireChoiceState,
QuestionnaireChoicesProps,
QuestionnaireChoicesState,
QuestionnaireContextValue,
QuestionnaireDescriptionProps,
QuestionnaireErrorProps,
QuestionnaireInputProps,
QuestionnaireInputState,
QuestionnaireInputType,
QuestionnaireItemContextValue,
QuestionnaireItemDefinition,
QuestionnaireItemProps,
QuestionnaireItemState,
QuestionnaireItemStatus,
QuestionnaireNavigationState,
QuestionnaireNextProps,
QuestionnairePreviousProps,
QuestionnaireProgressProps,
QuestionnaireProgressState,
QuestionnaireRootProps,
QuestionnaireRootState,
QuestionnaireShortcutMode,
QuestionnaireSkipProps,
QuestionnaireSubmitProps,
QuestionnaireTitleProps,
}

View File

@@ -0,0 +1,170 @@
import * as React from "react"
import { useQuestionnaireItemContext } from "./context"
import type {
QuestionnaireChoiceProps,
QuestionnaireChoiceState,
} from "./types"
import { getAnswerKeyShortcuts } from "./utils"
type UseQuestionnaireChoiceParameters = Pick<
QuestionnaireChoiceProps,
"checked" | "defaultChecked" | "disabled" | "onChange" | "value"
>
function useQuestionnaireChoice({
checked: controlledChecked,
defaultChecked = false,
disabled: choiceDisabled = false,
onChange,
value,
}: UseQuestionnaireChoiceParameters) {
const {
disabled: itemDisabled,
hasInputAnswer,
invalid,
multiple,
name: itemName,
registerAnswerControl,
registerAnswerSelection,
required,
resetVersion,
selectedAnswerIds,
setAnswerDefault,
setAnswerSelectionFromInteraction,
shortcutByAnswerId,
shortcutByChoiceValue,
status,
syncControlledAnswerSelection,
} = useQuestionnaireItemContext("Questionnaire.Choice")
const answerId = React.useId()
const [inputElement, setInputElement] =
React.useState<HTMLInputElement | null>(null)
const initialDefaultCheckedRef = React.useRef(defaultChecked)
const controlled = controlledChecked !== undefined
const disabled = itemDisabled || choiceDisabled
const selected = selectedAnswerIds.includes(answerId)
const checked = controlled
? status === "skipped"
? false
: controlledChecked
: selected
const type = multiple ? "checkbox" : "radio"
const shortcut =
shortcutByChoiceValue?.get(value) ??
shortcutByAnswerId.get(answerId) ??
null
React.useLayoutEffect(
() => registerAnswerSelection(answerId, initialDefaultCheckedRef.current),
[answerId, registerAnswerSelection]
)
React.useLayoutEffect(
() => setAnswerDefault(answerId, defaultChecked),
[answerId, defaultChecked, setAnswerDefault]
)
React.useLayoutEffect(() => {
if (!inputElement) {
return
}
return registerAnswerControl({
disabled,
element: inputElement,
id: answerId,
ownDisabled: choiceDisabled,
type: "choice",
value,
})
}, [
answerId,
choiceDisabled,
disabled,
inputElement,
registerAnswerControl,
value,
])
React.useLayoutEffect(() => {
if (controlled) {
syncControlledAnswerSelection(answerId, controlledChecked)
}
}, [
answerId,
controlled,
controlledChecked,
resetVersion,
syncControlledAnswerSelection,
])
React.useLayoutEffect(() => {
if (!inputElement) {
return
}
// Keep the native reset target aligned with Questionnaire's owned default,
// including controlled choices whose checked prop remains authoritative.
inputElement.defaultChecked = controlled
? controlledChecked
: defaultChecked
if (resetVersion > 0) {
inputElement.checked = checked
}
}, [
checked,
controlled,
controlledChecked,
defaultChecked,
inputElement,
resetVersion,
])
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange?.(event)
if (event.defaultPrevented) {
return
}
if (!controlled) {
setAnswerSelectionFromInteraction(answerId, event.target.checked)
return
}
if (status === "skipped" && controlledChecked === event.target.checked) {
setAnswerSelectionFromInteraction(answerId, controlledChecked)
}
}
const state: QuestionnaireChoiceState = {
checked,
disabled,
invalid,
shortcut,
type,
}
return {
inputProps: {
ref: setInputElement,
"aria-invalid": invalid || undefined,
"aria-keyshortcuts": getAnswerKeyShortcuts(
shortcut,
!disabled && checked
),
checked,
disabled,
id: answerId,
name: status === "skipped" ? undefined : itemName,
onChange: handleChange,
required: required && !multiple && !hasInputAnswer,
type,
value,
},
state,
}
}
export { useQuestionnaireChoice }

View File

@@ -0,0 +1,149 @@
import * as React from "react"
import { composeRefs } from "../use-render"
import { useQuestionnaireItemContext } from "./context"
import type { QuestionnaireInputProps, QuestionnaireInputState } from "./types"
import { getAnswerKeyShortcuts, hasInputValue } from "./utils"
type UseQuestionnaireInputParameters = Pick<
QuestionnaireInputProps,
"defaultValue" | "disabled" | "onChange" | "ref" | "type" | "value"
>
function useQuestionnaireInput({
defaultValue,
disabled: inputDisabled = false,
onChange,
ref,
type = "text",
value: controlledValue,
}: UseQuestionnaireInputParameters) {
const {
disabled: itemDisabled,
invalid,
name: itemName,
registerAnswerControl,
registerAnswerSelection,
resetVersion,
selectedAnswerIds,
setAnswerDefault,
setAnswerSelectionFromInteraction,
syncControlledAnswerSelection,
} = useQuestionnaireItemContext("Questionnaire.Input")
const answerId = React.useId()
const inputRef = React.useRef<HTMLInputElement | null>(null)
const initialDefaultFilledRef = React.useRef(hasInputValue(defaultValue))
const controlled = controlledValue !== undefined
const defaultFilled = hasInputValue(defaultValue)
const controlledFilled = hasInputValue(controlledValue)
const [uncontrolledFilled, setUncontrolledFilled] =
React.useState(defaultFilled)
const disabled = itemDisabled || inputDisabled
const filled = controlled ? controlledFilled : uncontrolledFilled
const selected = selectedAnswerIds.includes(answerId)
React.useLayoutEffect(
() => registerAnswerSelection(answerId, initialDefaultFilledRef.current),
[answerId, registerAnswerSelection]
)
React.useLayoutEffect(
() => setAnswerDefault(answerId, defaultFilled),
[defaultFilled, answerId, setAnswerDefault]
)
React.useLayoutEffect(() => {
const input = inputRef.current
if (!input) {
return
}
return registerAnswerControl({
disabled,
element: input,
id: answerId,
type: "input",
})
}, [disabled, answerId, registerAnswerControl])
React.useLayoutEffect(() => {
if (controlled) {
syncControlledAnswerSelection(answerId, controlledFilled)
return
}
if (resetVersion > 0) {
setUncontrolledFilled(defaultFilled)
}
}, [
controlled,
controlledFilled,
controlledValue,
defaultFilled,
answerId,
resetVersion,
syncControlledAnswerSelection,
])
React.useLayoutEffect(() => {
const input = inputRef.current
if (!input || !controlled) {
return
}
input.defaultValue = String(controlledValue)
}, [controlled, controlledValue])
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange?.(event)
if (event.defaultPrevented) {
return
}
const nextFilled = event.target.value.trim().length > 0
if (controlled) {
return
}
setUncontrolledFilled(nextFilled)
setAnswerSelectionFromInteraction(answerId, nextFilled)
}
const state: QuestionnaireInputState = {
disabled,
filled,
invalid,
}
const setInputRef = React.useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element
composeRefs(ref)?.(element)
},
[ref]
)
return {
inputProps: {
"aria-invalid": invalid || undefined,
"aria-keyshortcuts": getAnswerKeyShortcuts(
null,
!disabled && filled && selected
),
defaultValue: controlled ? undefined : defaultValue,
disabled,
form: selected ? undefined : "",
id: answerId,
name: selected ? itemName : undefined,
onChange: handleChange,
ref: setInputRef,
type,
value: controlled ? controlledValue : undefined,
},
state,
}
}
export { useQuestionnaireInput }

View File

@@ -0,0 +1,571 @@
import * as React from "react"
import { composeRefs } from "../use-render"
import { getShortcutByChoiceValue } from "./collection"
import { useQuestionnaireContext } from "./context"
import type {
AnswerControlRegistration,
QuestionnaireItemContextValue,
QuestionnaireItemProps,
QuestionnaireItemState,
QuestionnaireItemStatus,
} from "./types"
import {
compareAnswerOrder,
getShortcutKeys,
isAnswerFilled,
isEmptyNavigableInput,
isRadioTarget,
isTextEntryTarget,
} from "./utils"
type UseQuestionnaireItemParameters = Pick<
QuestionnaireItemProps,
| "aria-describedby"
| "aria-keyshortcuts"
| "disabled"
| "invalid"
| "multiple"
| "name"
| "onStatusChange"
| "ref"
| "required"
>
function useQuestionnaireItem({
"aria-describedby": ariaDescribedBy,
"aria-keyshortcuts": ariaKeyShortcuts,
disabled = false,
invalid: externallyInvalid = false,
multiple = false,
name,
onStatusChange,
ref,
required = false,
}: UseQuestionnaireItemParameters) {
const {
activeItemName,
domVersion,
first,
itemDefinitionByName,
last,
nativeValidation,
registerItem,
shortcuts,
} = useQuestionnaireContext("Questionnaire.Item")
const [element, setElement] = React.useState<HTMLFieldSetElement | null>(null)
const [answerControlRegistrations, setAnswerControlRegistrations] =
React.useState<AnswerControlRegistration[]>([])
const [validationAttempted, setValidationAttempted] = React.useState(false)
const [selectedAnswerIds, setSelectedAnswerIds] = React.useState<string[]>([])
const [skipped, setSkipped] = React.useState(false)
const [resetVersion, setResetVersion] = React.useState(0)
const [descriptionIds, setDescriptionIds] = React.useState<string[]>([])
const [errorIds, setErrorIds] = React.useState<string[]>([])
const defaultSelectedAnswerIdsRef = React.useRef<string[]>([])
const multipleRef = React.useRef(multiple)
const previousMultipleRef = React.useRef(multiple)
multipleRef.current = multiple
const active = !disabled && activeItemName === name
const answerControls = React.useMemo(
() => [...answerControlRegistrations].sort(compareAnswerOrder),
[answerControlRegistrations, domVersion]
)
const answers = React.useMemo(
() => answerControls.filter((registration) => !registration.disabled),
[answerControls]
)
const answered = answers.some((answer) =>
selectedAnswerIds.includes(answer.id)
)
const status: QuestionnaireItemStatus = skipped
? "skipped"
: answered
? "answered"
: "unanswered"
const intentionallySkipped = status === "skipped" && !required
const valid =
disabled ||
intentionallySkipped ||
(!externallyInvalid && status === "answered")
const invalid =
!disabled &&
!intentionallySkipped &&
(externallyInvalid || (validationAttempted && !valid))
const hasInputAnswer = answers.some((answer) => answer.type === "input")
const previousStatusRef = React.useRef(status)
const itemDefinition = itemDefinitionByName?.get(name)
const shortcutByChoiceValue = React.useMemo(
() =>
itemDefinitionByName
? getShortcutByChoiceValue(itemDefinition, shortcuts)
: null,
[itemDefinition, itemDefinitionByName, shortcuts]
)
const shortcutByAnswerId = React.useMemo(() => {
if (shortcutByChoiceValue) {
return new Map<string, string>()
}
const keys = getShortcutKeys(shortcuts)
const shortcutAnswers = answers.filter((answer) => answer.type === "choice")
return new Map(
shortcutAnswers
.slice(0, keys.length)
.map((answer, index) => [answer.id, keys[index]])
)
}, [answers, shortcutByChoiceValue, shortcuts])
const registerAnswerControl = React.useCallback(
(registration: AnswerControlRegistration) => {
setAnswerControlRegistrations((currentRegistrations) => [
...currentRegistrations.filter(
(currentRegistration) =>
currentRegistration.element !== registration.element &&
currentRegistration.id !== registration.id
),
registration,
])
return () => {
setAnswerControlRegistrations((currentRegistrations) =>
currentRegistrations.filter(
(currentRegistration) => currentRegistration !== registration
)
)
}
},
[]
)
React.useLayoutEffect(() => {
if (previousStatusRef.current === status) {
return
}
previousStatusRef.current = status
onStatusChange?.(status)
}, [onStatusChange, status])
const updateAnswerSelected = React.useCallback(
(answerId: string, selected: boolean) => {
setSelectedAnswerIds((currentAnswerIds) => {
if (!selected) {
return currentAnswerIds.filter(
(currentAnswerId) => currentAnswerId !== answerId
)
}
if (!multiple) {
return [answerId]
}
return currentAnswerIds.includes(answerId)
? currentAnswerIds
: [...currentAnswerIds, answerId]
})
},
[multiple]
)
const setAnswerSelectionFromInteraction = React.useCallback(
(answerId: string, selected: boolean) => {
setSkipped(false)
updateAnswerSelected(answerId, selected)
},
[updateAnswerSelected]
)
const syncControlledAnswerSelection = React.useCallback(
(answerId: string, selected: boolean) => {
if (selected) {
setSkipped(false)
}
updateAnswerSelected(answerId, selected)
},
[updateAnswerSelected]
)
const registerAnswerSelection = React.useCallback(
(answerId: string, defaultSelected: boolean) => {
if (defaultSelected) {
defaultSelectedAnswerIdsRef.current = [
...defaultSelectedAnswerIdsRef.current.filter(
(currentAnswerId) => currentAnswerId !== answerId
),
answerId,
]
setSelectedAnswerIds((currentAnswerIds) => {
if (!multipleRef.current) {
return currentAnswerIds.length ? currentAnswerIds : [answerId]
}
return currentAnswerIds.includes(answerId)
? currentAnswerIds
: [...currentAnswerIds, answerId]
})
}
return () => {
defaultSelectedAnswerIdsRef.current =
defaultSelectedAnswerIdsRef.current.filter(
(currentAnswerId) => currentAnswerId !== answerId
)
setSelectedAnswerIds((currentAnswerIds) =>
currentAnswerIds.filter(
(currentAnswerId) => currentAnswerId !== answerId
)
)
}
},
[]
)
const setAnswerDefault = React.useCallback(
(answerId: string, defaultSelected: boolean) => {
if (defaultSelected) {
defaultSelectedAnswerIdsRef.current =
defaultSelectedAnswerIdsRef.current.includes(answerId)
? defaultSelectedAnswerIdsRef.current
: [...defaultSelectedAnswerIdsRef.current, answerId]
return
}
defaultSelectedAnswerIdsRef.current =
defaultSelectedAnswerIdsRef.current.filter(
(currentAnswerId) => currentAnswerId !== answerId
)
},
[]
)
const registerDescription = React.useCallback(
(registeredDescriptionId: string) => {
setDescriptionIds((currentDescriptionIds) =>
currentDescriptionIds.includes(registeredDescriptionId)
? currentDescriptionIds
: [...currentDescriptionIds, registeredDescriptionId]
)
return () => {
setDescriptionIds((currentDescriptionIds) =>
currentDescriptionIds.filter(
(currentDescriptionId) =>
currentDescriptionId !== registeredDescriptionId
)
)
}
},
[]
)
const registerError = React.useCallback((registeredErrorId: string) => {
setErrorIds((currentErrorIds) =>
currentErrorIds.includes(registeredErrorId)
? currentErrorIds
: [...currentErrorIds, registeredErrorId]
)
return () => {
setErrorIds((currentErrorIds) =>
currentErrorIds.filter(
(currentErrorId) => currentErrorId !== registeredErrorId
)
)
}
}, [])
const validate = React.useCallback(() => {
setValidationAttempted(true)
if (!valid) {
return false
}
if (!nativeValidation) {
return true
}
const invalidAnswer = answers.find(
(answer) =>
isAnswerFilled(answer) &&
answer.element.willValidate &&
!answer.element.validity.valid
)
if (!invalidAnswer) {
return true
}
invalidAnswer.element.focus()
invalidAnswer.element.reportValidity()
return false
}, [answers, nativeValidation, valid])
const focus = React.useCallback(() => {
element?.focus()
}, [element])
const focusInvalid = React.useCallback(() => {
const selectedInput = element?.querySelector<HTMLInputElement>(
"input[data-filled][name]:not(:disabled)"
)
const firstControl = element?.querySelector<HTMLElement>(
"input:not([type=hidden]):not(:disabled), textarea:not(:disabled)"
)
;(selectedInput ?? firstControl ?? element)?.focus()
}, [element])
const reset = React.useCallback(() => {
setValidationAttempted(false)
setSkipped(false)
setSelectedAnswerIds(
multiple
? [...defaultSelectedAnswerIdsRef.current]
: defaultSelectedAnswerIdsRef.current.slice(0, 1)
)
setResetVersion((version) => version + 1)
}, [multiple])
const skip = React.useCallback(() => {
if (required) {
return
}
setSelectedAnswerIds([])
setSkipped(true)
}, [required])
React.useLayoutEffect(() => {
const wasMultiple = previousMultipleRef.current
previousMultipleRef.current = multiple
if (!wasMultiple || multiple) {
return
}
setSelectedAnswerIds((currentAnswerIds) => {
const selectedAnswer = answers.find((answer) =>
currentAnswerIds.includes(answer.id)
)
return selectedAnswer ? [selectedAnswer.id] : []
})
}, [answers, multiple])
const getAnswerByElement = React.useCallback(
(answerElement: Element) =>
answers.find((answer) => answer.element === answerElement) ?? null,
[answers]
)
const getAnswerByShortcut = React.useCallback(
(shortcut: string) => {
if (shortcutByChoiceValue) {
const choiceValue = Array.from(shortcutByChoiceValue.entries()).find(
([, choiceShortcut]) => choiceShortcut === shortcut
)?.[0]
return (
answers.find(
(answer) => answer.type === "choice" && answer.value === choiceValue
) ?? null
)
}
const answerId = Array.from(shortcutByAnswerId.entries()).find(
([, answerShortcut]) => answerShortcut === shortcut
)?.[0]
return answers.find((answer) => answer.id === answerId) ?? null
},
[answers, shortcutByAnswerId, shortcutByChoiceValue]
)
const moveAnswerFocus = React.useCallback(
(currentElement: Element, direction: "next" | "previous") => {
const currentIndex = answers.findIndex(
(answer) => answer.element === currentElement
)
const currentAnswer =
currentIndex < 0 ? null : (answers[currentIndex] ?? null)
if (
!answers.length ||
(isTextEntryTarget(currentElement) &&
!isEmptyNavigableInput(currentAnswer)) ||
(currentIndex < 0 && currentElement !== element)
) {
return false
}
const nextAnswer =
currentIndex < 0
? (answers.find(isAnswerFilled) ??
(direction === "next" ? answers[0] : answers[answers.length - 1]))
: answers[
(currentIndex +
(direction === "next" ? 1 : -1) +
answers.length) %
answers.length
]
if (!nextAnswer || nextAnswer.element === currentElement) {
return false
}
if (
currentIndex >= 0 &&
isRadioTarget(currentElement) &&
isRadioTarget(nextAnswer.element)
) {
return false
}
nextAnswer.element.focus()
if (nextAnswer.type === "choice" && isRadioTarget(nextAnswer.element)) {
nextAnswer.element.click()
}
return true
},
[answers, element]
)
React.useLayoutEffect(() => {
if (!element) {
return
}
return registerItem({
choices: answerControls.flatMap((answer) =>
answer.type === "choice"
? [{ disabled: answer.ownDisabled, value: answer.value }]
: []
),
disabled,
element,
focus,
focusInvalid,
getAnswerByElement,
getAnswerByShortcut,
moveAnswerFocus,
name,
required,
reset,
skip,
status,
validate,
})
}, [
answerControls,
disabled,
element,
focus,
focusInvalid,
getAnswerByElement,
getAnswerByShortcut,
moveAnswerFocus,
name,
registerItem,
required,
reset,
skip,
status,
validate,
])
const context = React.useMemo<QuestionnaireItemContextValue>(
() => ({
active,
disabled,
hasInputAnswer,
invalid,
multiple,
name,
registerAnswerControl,
registerAnswerSelection,
registerDescription,
registerError,
required,
resetVersion,
selectedAnswerIds,
setAnswerDefault,
setAnswerSelectionFromInteraction,
shortcutByAnswerId,
shortcutByChoiceValue,
shortcuts,
status,
syncControlledAnswerSelection,
}),
[
active,
disabled,
hasInputAnswer,
invalid,
multiple,
name,
registerAnswerControl,
registerAnswerSelection,
registerDescription,
registerError,
required,
resetVersion,
selectedAnswerIds,
setAnswerDefault,
setAnswerSelectionFromInteraction,
shortcutByAnswerId,
shortcutByChoiceValue,
shortcuts,
status,
syncControlledAnswerSelection,
]
)
const setItemRef = React.useCallback(
(nextElement: HTMLFieldSetElement | null) => {
setElement(nextElement)
composeRefs(ref)?.(nextElement)
},
[ref]
)
const describedBy =
[...descriptionIds, ...(invalid ? errorIds : []), ariaDescribedBy]
.filter(Boolean)
.join(" ") || undefined
const keyShortcuts =
[
ariaKeyShortcuts,
active ? "Meta+Enter Control+Enter" : undefined,
active && answers.length ? "ArrowUp ArrowDown" : undefined,
active && !first ? "ArrowLeft" : undefined,
active && !last && status !== "unanswered" ? "ArrowRight" : undefined,
]
.filter(Boolean)
.join(" ") || undefined
const state: QuestionnaireItemState = {
active,
disabled,
invalid,
multiple,
required,
status,
}
return {
context,
itemProps: {
"aria-describedby": describedBy,
"aria-invalid": invalid || undefined,
"aria-keyshortcuts": keyShortcuts,
disabled,
hidden: !active,
inert: !active,
ref: setItemRef,
tabIndex: -1,
},
state,
}
}
export { useQuestionnaireItem }

View File

@@ -0,0 +1,525 @@
import * as React from "react"
import { composeRefs } from "../use-render"
import {
createQuestionnaireCollection,
getCollectionDefinitionWarnings,
getCollectionRegistrationWarnings,
getInitialItemName,
} from "./collection"
import type {
ItemRegistration,
PendingFocus,
QuestionnaireContextValue,
QuestionnaireRootProps,
QuestionnaireRootState,
} from "./types"
import {
compareItemOrder,
getShortcutFromKey,
isAnswerFilled,
isRadioTarget,
isTextEntryTarget,
} from "./utils"
type UseQuestionnaireRootParameters = Pick<
QuestionnaireRootProps,
| "defaultItem"
| "item"
| "items"
| "noValidate"
| "onItemChange"
| "onReset"
| "onSubmit"
| "ref"
| "shortcuts"
>
function useQuestionnaireRoot({
defaultItem,
item: controlledItem,
items: itemDefinitions,
noValidate,
onItemChange,
onReset,
onSubmit,
ref,
shortcuts: shortcutMode,
}: UseQuestionnaireRootParameters) {
const collection = React.useMemo(
() => createQuestionnaireCollection(itemDefinitions),
[itemDefinitions]
)
const [registrations, setRegistrations] = React.useState<ItemRegistration[]>(
[]
)
const [uncontrolledItem, setUncontrolledItem] = React.useState<string | null>(
() => getInitialItemName(collection, defaultItem)
)
const [rootElement, setRootElement] = React.useState<HTMLFormElement | null>(
null
)
const [domVersion, setDomVersion] = React.useState(0)
const pendingFocusRef = React.useRef<PendingFocus | null>(null)
const controlled = controlledItem !== undefined
const activeItemName = controlled ? controlledItem : uncontrolledItem
const previousActiveItemNameRef = React.useRef(activeItemName)
const nativeValidation = noValidate === false
const shortcuts = shortcutMode ?? null
const activeWarningsRef = React.useRef(new Set<string>())
React.useLayoutEffect(() => {
if (process.env.NODE_ENV === "production") {
return
}
if (!collection || !rootElement) {
activeWarningsRef.current.clear()
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) {
return
}
const warnings = [
...getCollectionDefinitionWarnings(collection, defaultItem),
...getCollectionRegistrationWarnings(
collection,
registrations,
shortcuts
),
]
const activeWarnings = new Set(warnings)
activeWarnings.forEach((warning) => {
if (!activeWarningsRef.current.has(warning)) {
console.warn(`[Questionnaire] ${warning}`)
}
})
activeWarningsRef.current = activeWarnings
})
return () => {
cancelled = true
}
}, [collection, defaultItem, registrations, rootElement, shortcuts])
React.useLayoutEffect(() => {
if (!rootElement || typeof MutationObserver === "undefined") {
return
}
const observer = new MutationObserver(() => {
setDomVersion((version) => version + 1)
})
observer.observe(rootElement, { childList: true, subtree: true })
return () => observer.disconnect()
}, [rootElement])
const runtimeItems = React.useMemo(
() =>
registrations
.filter((registration) => !registration.disabled)
.sort(compareItemOrder),
[domVersion, registrations]
)
const runtimeItemByName = React.useMemo(
() =>
new Map(
runtimeItems.map((runtimeItem) => [runtimeItem.name, runtimeItem])
),
[runtimeItems]
)
const logicalItems = collection?.enabledItems ?? runtimeItems
const currentIndex = logicalItems.findIndex(
(logicalItem) => logicalItem.name === activeItemName
)
const activeItem =
currentIndex < 0 || !activeItemName
? null
: (runtimeItemByName.get(activeItemName) ?? null)
const activeDefinition = activeItemName
? collection?.itemByName.get(activeItemName)
: undefined
const activeItemRequired =
currentIndex < 0
? null
: activeDefinition
? Boolean(activeDefinition.required)
: (activeItem?.required ?? false)
const activeItemStatus =
currentIndex < 0
? null
: (activeItem?.status ?? (activeItemName ? "unanswered" : null))
const orderedRegistrations = React.useMemo(
() =>
collection
? collection.enabledItems.flatMap((definition) => {
const registration = runtimeItemByName.get(definition.name)
return registration ? [registration] : []
})
: runtimeItems,
[collection, runtimeItemByName, runtimeItems]
)
const total = logicalItems.length
const current = currentIndex < 0 ? 0 : currentIndex + 1
const first = total > 0 && currentIndex === 0
const last = total > 0 && currentIndex === total - 1
const setItem = React.useCallback(
(nextItem: string, focusTarget: PendingFocus["target"] = "item") => {
if (nextItem === activeItemName) {
return
}
pendingFocusRef.current = {
name: nextItem,
target: focusTarget,
}
if (!controlled) {
setUncontrolledItem(nextItem)
}
onItemChange?.(nextItem)
},
[activeItemName, controlled, onItemChange]
)
React.useLayoutEffect(() => {
if (total === 0) {
return
}
if (currentIndex < 0) {
if (!controlled && activeItemName === null) {
setUncontrolledItem(logicalItems[0].name)
return
}
setItem(logicalItems[0].name)
return
}
const pendingFocus = pendingFocusRef.current
const activeItemChanged =
previousActiveItemNameRef.current !== activeItemName
previousActiveItemNameRef.current = activeItemName
if (!pendingFocus || pendingFocus.name !== activeItemName) {
if (controlled && activeItemChanged) {
pendingFocusRef.current = null
activeItem?.focus()
}
return
}
if (pendingFocus.target === "invalid") {
activeItem?.focusInvalid()
} else {
activeItem?.focus()
}
pendingFocusRef.current = null
}, [
activeItem,
activeItemName,
controlled,
currentIndex,
logicalItems,
setItem,
total,
])
const registerItem = React.useCallback((registration: ItemRegistration) => {
setRegistrations((currentRegistrations) => [
...currentRegistrations.filter(
(currentRegistration) =>
currentRegistration.element !== registration.element &&
currentRegistration.name !== registration.name
),
registration,
])
return () => {
setRegistrations((currentRegistrations) =>
currentRegistrations.filter(
(currentRegistration) => currentRegistration !== registration
)
)
}
}, [])
const goPrevious = React.useCallback(() => {
if (currentIndex <= 0) {
return
}
setItem(logicalItems[currentIndex - 1].name)
}, [currentIndex, logicalItems, setItem])
const goNext = React.useCallback(() => {
if (!activeItem || currentIndex >= total - 1) {
return
}
if (!activeItem.validate()) {
activeItem.focusInvalid()
return
}
setItem(logicalItems[currentIndex + 1].name)
}, [activeItem, currentIndex, logicalItems, setItem, total])
const confirmCurrent = React.useCallback(() => {
if (!activeItem) {
return
}
if (!activeItem.validate()) {
activeItem.focusInvalid()
return
}
if (last) {
rootElement?.requestSubmit()
return
}
setItem(logicalItems[currentIndex + 1].name)
}, [activeItem, currentIndex, last, logicalItems, rootElement, setItem])
const skipCurrent = React.useCallback(() => {
if (!activeItem || activeItem.required) {
return
}
activeItem.skip()
if (!last) {
setItem(logicalItems[currentIndex + 1].name)
return
}
queueMicrotask(() => {
rootElement?.requestSubmit()
})
}, [activeItem, currentIndex, last, logicalItems, rootElement, setItem])
function handleReset(event: React.FormEvent<HTMLFormElement>) {
onReset?.(event)
if (event.defaultPrevented) {
return
}
for (const registration of registrations) {
registration.reset()
}
const resetItemName = collection
? getInitialItemName(collection, defaultItem)
: (runtimeItems.find((registration) => registration.name === defaultItem)
?.name ?? runtimeItems[0]?.name)
if (resetItemName) {
setItem(resetItemName)
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
const firstInvalidItem = orderedRegistrations.find(
(registration) => !registration.validate()
)
if (firstInvalidItem) {
event.preventDefault()
setItem(firstInvalidItem.name, "invalid")
if (firstInvalidItem.name === activeItemName) {
firstInvalidItem.focusInvalid()
pendingFocusRef.current = null
}
return
}
onSubmit?.(event)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLFormElement>) {
if (
event.defaultPrevented ||
event.nativeEvent.isComposing ||
event.keyCode === 229 ||
!activeItem ||
!(event.target instanceof Element)
) {
return
}
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey
) {
event.preventDefault()
if (!event.repeat) {
confirmCurrent()
}
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
const moved = activeItem.moveAnswerFocus(
event.target,
event.key === "ArrowDown" ? "next" : "previous"
)
if (moved) {
event.preventDefault()
return
}
}
if (
(event.key === "ArrowLeft" || event.key === "ArrowRight") &&
!isTextEntryTarget(event.target) &&
!isRadioTarget(event.target)
) {
event.preventDefault()
if (event.repeat) {
return
}
if (event.key === "ArrowLeft") {
goPrevious()
} else if (activeItem.status !== "unanswered") {
goNext()
}
return
}
if (event.key === "Enter") {
const answer = activeItem.getAnswerByElement(event.target)
if (!answer) {
return
}
event.preventDefault()
if (!event.repeat && isAnswerFilled(answer)) {
confirmCurrent()
}
return
}
if (!shortcuts || isTextEntryTarget(event.target)) {
return
}
const shortcut = getShortcutFromKey(event.key, shortcuts)
const answer = shortcut ? activeItem.getAnswerByShortcut(shortcut) : null
if (!answer) {
return
}
event.preventDefault()
if (event.repeat) {
return
}
answer.element.focus()
if (answer.type === "choice") {
answer.element.click()
}
}
const state: QuestionnaireRootState = {
current,
first,
last,
total,
}
const context = React.useMemo<QuestionnaireContextValue>(
() => ({
...state,
activeItem,
activeItemName,
activeItemRequired,
activeItemStatus,
domVersion,
goNext,
goPrevious,
itemDefinitionByName: collection?.itemByName ?? null,
nativeValidation,
registerItem,
shortcuts,
skipCurrent,
}),
[
activeItem,
activeItemName,
activeItemRequired,
activeItemStatus,
collection,
current,
domVersion,
first,
goNext,
goPrevious,
last,
nativeValidation,
registerItem,
shortcuts,
skipCurrent,
total,
]
)
const setRootRef = React.useCallback(
(element: HTMLFormElement | null) => {
setRootElement(element)
composeRefs(ref)?.(element)
},
[ref]
)
return {
context,
rootProps: {
"data-shortcuts": shortcuts ?? undefined,
onKeyDown: handleKeyDown,
onReset: handleReset,
onSubmit: handleSubmit,
ref: setRootRef,
},
state,
}
}
export { useQuestionnaireRoot }

View File

@@ -0,0 +1,141 @@
import type {
AnswerControlRegistration,
ItemRegistration,
QuestionnaireShortcutMode,
} from "./types"
function hasInputValue(value: unknown) {
if (Array.isArray(value)) {
return value.some((item) => String(item).trim().length > 0)
}
return (
value !== undefined && value !== null && String(value).trim().length > 0
)
}
function getShortcutKeys(shortcuts: QuestionnaireShortcutMode | null) {
if (shortcuts === "letters") {
return Array.from({ length: 26 }, (_, index) =>
String.fromCharCode(65 + index)
)
}
if (shortcuts === "numbers") {
return Array.from({ length: 9 }, (_, index) => String(index + 1))
}
return []
}
function getShortcutFromKey(key: string, shortcuts: QuestionnaireShortcutMode) {
const normalizedKey = shortcuts === "letters" ? key.toUpperCase() : key
return getShortcutKeys(shortcuts).includes(normalizedKey)
? normalizedKey
: null
}
function getAnswerKeyShortcuts(shortcut: string | null, filled: boolean) {
return (
[shortcut, filled ? "Enter" : null].filter(Boolean).join(" ") || undefined
)
}
function isAnswerFilled(answer: AnswerControlRegistration) {
if (answer.type === "choice") {
return answer.element.checked
}
return (
answer.element.hasAttribute("name") && hasInputValue(answer.element.value)
)
}
function isEmptyNavigableInput(answer: AnswerControlRegistration | null) {
return (
answer?.type === "input" &&
["email", "password", "search", "tel", "text", "url"].includes(
answer.element.type
) &&
!hasInputValue(answer.element.value)
)
}
function isTextEntryTarget(element: Element) {
if (
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
) {
return true
}
if (element instanceof HTMLInputElement) {
return !["button", "checkbox", "radio", "reset", "submit"].includes(
element.type
)
}
return element instanceof HTMLElement && element.isContentEditable
}
function isRadioTarget(element: Element) {
return element instanceof HTMLInputElement && element.type === "radio"
}
function compareItemOrder(
firstItem: ItemRegistration,
secondItem: ItemRegistration
) {
if (firstItem.element === secondItem.element) {
return 0
}
const position = firstItem.element.compareDocumentPosition(secondItem.element)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1
}
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
return 1
}
return 0
}
function compareAnswerOrder(
firstAnswer: AnswerControlRegistration,
secondAnswer: AnswerControlRegistration
) {
if (firstAnswer.element === secondAnswer.element) {
return 0
}
const position = firstAnswer.element.compareDocumentPosition(
secondAnswer.element
)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1
}
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
return 1
}
return 0
}
export {
compareAnswerOrder,
compareItemOrder,
getAnswerKeyShortcuts,
getShortcutFromKey,
getShortcutKeys,
hasInputValue,
isAnswerFilled,
isEmptyNavigableInput,
isRadioTarget,
isTextEntryTarget,
}

View File

@@ -0,0 +1,16 @@
# use-render
Internal polymorphic-render helper (`useRender`, `mergeProps`) that powers the
`render` prop on `@shadcn/react` primitives. It lets a component render as a
custom element while merging the primitive's props, refs, and state attributes
onto it.
## Attribution
This code is adapted from [Base UI](https://github.com/mui/base-ui)'s
`useRender` implementation (`@base-ui/react/use-render`), MIT licensed,
© MUI. We keep a local copy so the primitives carry no runtime dependency on
Base UI.
If Base UI publishes `useRender` as an independent, standalone package, we will
switch to it and remove this copy.

View File

@@ -0,0 +1,208 @@
// A poor man's version of base-ui useRender.
// see https://base-ui.com/react/utils/use-render.
// TODO: Replace if base-ui useRender is published as a package.
import * as React from "react"
type RenderState = Record<string, unknown>
type RenderFunction<TState extends RenderState> = (
props: Record<string, unknown>,
state: TState
) => React.ReactElement | null
type RenderProp<TState extends RenderState> =
| React.ReactElement
| RenderFunction<TState>
type StateAttributesMapping<TState extends RenderState> = Partial<{
[K in keyof TState]: (
value: TState[K]
) => Record<string, string | undefined> | null | undefined
}>
type UseRenderComponentProps<
TElement extends React.ElementType,
TState extends RenderState = RenderState,
> = React.ComponentPropsWithRef<TElement> & {
render?: RenderProp<TState>
}
type UseRenderOptions<
TElement extends React.ElementType,
TState extends RenderState,
> = {
defaultTagName: TElement
props?: React.ComponentPropsWithRef<TElement>
render?: RenderProp<TState>
state?: TState
stateAttributesMapping?: StateAttributesMapping<TState>
}
function useRender<
TElement extends React.ElementType,
TState extends RenderState = RenderState,
>({
defaultTagName,
props,
render,
state = {} as TState,
stateAttributesMapping,
}: UseRenderOptions<TElement, TState>) {
const elementProps = mergeProps<TElement>(
getStateAttributes(state, stateAttributesMapping),
props
)
if (!render) {
return React.createElement(defaultTagName, elementProps)
}
if (typeof render === "function") {
return render(elementProps, state)
}
if (!React.isValidElement(render)) {
return null
}
const renderProps = render.props as Record<string, unknown>
const propsWithRenderProps = mergeProps<TElement>(elementProps, renderProps)
const propsWithRef = {
...propsWithRenderProps,
ref: composeRefs(
elementProps.ref as React.Ref<unknown> | undefined,
renderProps.ref as React.Ref<unknown> | undefined
),
}
return React.cloneElement(
render,
propsWithRef as React.Attributes & Record<string, unknown>
)
}
function mergeProps<TElement extends React.ElementType>(
...sources: Array<
React.ComponentPropsWithRef<TElement> | Record<string, unknown> | undefined
>
) {
const result: Record<string, unknown> = {}
for (const source of sources) {
if (!source) {
continue
}
const sourceProps = source as Record<string, unknown>
for (const key of Object.keys(sourceProps)) {
const value = sourceProps[key]
if (value === undefined) {
continue
}
const current = result[key]
if (key === "className") {
result[key] = [current, value].filter(Boolean).join(" ")
} else if (key === "style") {
result[key] = {
...(current as React.CSSProperties | undefined),
...(value as React.CSSProperties | undefined),
}
} else if (key === "ref") {
result[key] = composeRefs(
current as React.Ref<unknown> | undefined,
value as React.Ref<unknown> | undefined
)
} else if (
isEventHandler(key) &&
typeof current === "function" &&
typeof value === "function"
) {
result[key] = composeEventHandlers(
value as EventHandler,
current as EventHandler
)
} else {
result[key] = value
}
}
}
return result as React.ComponentPropsWithRef<TElement>
}
function getStateAttributes<TState extends RenderState>(
state: TState,
mapping?: StateAttributesMapping<TState>
) {
const props: Record<string, unknown> = {}
for (const key of Object.keys(state) as Array<keyof TState>) {
const value = state[key]
const attributes = mapping?.[key]?.(value)
if (attributes) {
Object.assign(props, attributes)
continue
}
if (key === "slot") {
props["data-slot"] = value
continue
}
const attribute = `data-${String(key).replace(
/[A-Z]/g,
(letter) => `-${letter.toLowerCase()}`
)}`
if (typeof value === "boolean") {
props[attribute] = value ? "" : undefined
} else if (value !== null && value !== undefined) {
props[attribute] = String(value)
}
}
return props
}
type EventHandler = (event: React.SyntheticEvent) => void
function composeEventHandlers(theirs: EventHandler, ours: EventHandler) {
return function handleEvent(event: React.SyntheticEvent) {
theirs(event)
if (!event.defaultPrevented) {
ours(event)
}
}
}
function isEventHandler(key: string) {
return /^on[A-Z]/.test(key)
}
function composeRefs<T>(
...refs: Array<React.Ref<T> | undefined>
): React.RefCallback<T> | undefined {
const validRefs = refs.filter(Boolean)
if (validRefs.length === 0) {
return undefined
}
return (value) => {
for (const ref of validRefs) {
if (typeof ref === "function") {
ref(value)
} else if (ref) {
ref.current = value
}
}
}
}
export { composeRefs, mergeProps, useRender, type UseRenderComponentProps }

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"isolatedModules": false,
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,47 @@
import { readFileSync, writeFileSync } from "fs"
import { defineConfig } from "tsup"
const CLIENT_UI_ENTRIES = [
"dist/message-scroller/index.js",
"dist/questionnaire/index.js",
]
export default defineConfig((options) => ({
clean: !options.watch,
dts: true,
entry: {
"message-scroller/index": "src/message-scroller/index.ts",
"questionnaire/index": "src/questionnaire/index.ts",
},
format: ["esm"],
sourcemap: false,
minify: true,
target: "esnext",
outDir: "dist",
treeshake: true,
plugins: [useClientDirectivePlugin(CLIENT_UI_ENTRIES)],
}))
// Prepends `"use client"` to listed dist entry files after the build. Bundlers
// strip the directive from source, but RSC apps need it on the published module
// so imports like `@shadcn/react/message-scroller` resolve as client boundaries.
function useClientDirectivePlugin(entries: string[]) {
return {
name: "use-client-directive",
buildEnd({ writtenFiles }: { writtenFiles: { name: string }[] }) {
const uiFiles = writtenFiles.filter((file) =>
entries.some((entry) => file.name.replace(/\\/g, "/").endsWith(entry))
)
for (const file of uiFiles) {
const code = readFileSync(file.name, "utf8")
if (/^["']use client["'];?/.test(code)) {
continue
}
writeFileSync(file.name, `"use client";\n${code}`)
}
},
}
}

View File

@@ -0,0 +1,25 @@
import tsconfigPaths from "vite-tsconfig-paths"
import { defineConfig } from "vitest/config"
// Browser tests to cover what jsdom cannot.
// for example native scroll anchoring on prepend.
export default defineConfig({
test: {
include: ["src/**/*.browser.test.{ts,tsx}"],
setupFiles: ["./vitest.browser.setup.ts"],
browser: {
enabled: true,
provider: "playwright",
headless: true,
instances: [{ browser: "chromium" }],
},
},
optimizeDeps: {
include: ["react", "react-dom/client", "react/jsx-dev-runtime"],
},
plugins: [
tsconfigPaths({
ignoreConfigErrors: true,
}),
],
})

View File

@@ -0,0 +1,4 @@
// React's act() needs this flag outside of a testing-library environment.
;(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true

View File

@@ -0,0 +1,19 @@
import tsconfigPaths from "vite-tsconfig-paths"
import { configDefaults, defineConfig } from "vitest/config"
export default defineConfig({
test: {
exclude: [
...configDefaults.exclude,
"**/node_modules/**",
// Browser-mode tests run via vitest.browser.config.ts, not in jsdom.
"**/*.browser.test.*",
],
testTimeout: 8000,
},
plugins: [
tsconfigPaths({
ignoreConfigErrors: true,
}),
],
})

3
packages/shadcn/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
components
dist
.turbo

View File

@@ -0,0 +1 @@
test

1025
packages/shadcn/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

61
packages/shadcn/README.md Normal file
View File

@@ -0,0 +1,61 @@
# shadcn
A CLI for adding components to your project.
## create
Use the `create` command to create a new project. You will be taken to a website to build your custom design system and choose your framework.
```bash
npx shadcn create
```
## init
Use the `init` command to initialize dependencies for a new project.
The `init` command installs dependencies, adds the `cn` util, configures Tailwind CSS, and CSS variables for the project.
```bash
npx shadcn init
```
## apply
Use the `apply` command to apply a preset to an existing project.
The `apply` command overwrites the current preset configuration, reinstalls detected UI components, and updates fonts and CSS variables to match the new preset.
```bash
npx shadcn apply --preset a2r6bw
```
## add
Use the `add` command to add components to your project.
The `add` command adds a component to your project and installs all required dependencies.
```bash
npx shadcn add [component]
```
### Example
```bash
npx shadcn add alert-dialog
```
You can also run the command without any arguments to view a list of all available components:
```bash
npx shadcn add
```
## Documentation
Visit https://ui.shadcn.com/docs/cli to view the documentation.
## License
Licensed under the [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).

View File

@@ -0,0 +1,135 @@
{
"name": "shadcn",
"version": "4.21.0",
"description": "Add components to your apps.",
"homepage": "https://ui.shadcn.com",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"author": {
"name": "shadcn",
"url": "https://twitter.com/shadcn"
},
"repository": {
"type": "git",
"url": "https://github.com/shadcn-ui/ui.git",
"directory": "packages/shadcn"
},
"files": [
"dist"
],
"keywords": [
"components",
"ui",
"tailwind",
"radix-ui",
"base-ui",
"shadcn"
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./registry": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"default": "./dist/schema/index.js"
},
"./mcp": {
"types": "./dist/mcp/index.d.ts",
"default": "./dist/mcp/index.js"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.js"
},
"./icons": {
"types": "./dist/icons/index.d.ts",
"default": "./dist/icons/index.js"
},
"./preset": {
"types": "./dist/preset/index.d.ts",
"default": "./dist/preset/index.js"
},
"./tailwind.css": "./dist/tailwind.css"
},
"sideEffects": [
"./dist/tailwind.css"
],
"bin": "./dist/index.js",
"engines": {
"node": ">=20.18.1"
},
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist && rimraf components",
"start:dev": "cross-env REGISTRY_URL=http://localhost:4000/r SHADCN_TEMPLATE_DIR=../../templates node dist/index.js",
"start:prod": "cross-env REGISTRY_URL=https://ui.shadcn.com/r node dist/index.js",
"start": "node dist/index.js",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"release": "changeset version",
"pub:beta": "pnpm build && pnpm publish --no-git-checks --access public --tag beta",
"pub:rc": "pnpm build && pnpm publish --no-git-checks --access public --tag rc",
"pub:next": "pnpm build && pnpm publish --no-git-checks --access public --tag next",
"pub:release": "pnpm build && pnpm publish --access public",
"test": "vitest run",
"test:dev": "REGISTRY_URL=http://localhost:4000/r SHADCN_TEMPLATE_DIR=../../templates vitest run",
"mcp:inspect": "pnpm dlx @modelcontextprotocol/inspector node dist/index.js mcp"
},
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/plugin-transform-typescript": "^7.28.0",
"@babel/preset-typescript": "^7.27.1",
"@dotenvx/dotenvx": "^1.48.4",
"@modelcontextprotocol/sdk": "^1.26.0",
"@types/validate-npm-package-name": "^4.0.2",
"browserslist": "^4.26.2",
"cn": "^0.2.4",
"commander": "^14.0.0",
"cosmiconfig": "^9.0.0",
"dedent": "^1.6.0",
"deepmerge": "^4.3.1",
"diff": "^8.0.2",
"execa": "^9.6.0",
"fast-glob": "^3.3.3",
"fs-extra": "^11.3.1",
"fuzzysort": "^3.1.0",
"kleur": "^4.1.5",
"open": "^11.0.0",
"ora": "^8.2.0",
"postcss": "^8.5.23",
"postcss-selector-parser": "^7.1.3",
"prompts": "^2.4.2",
"recast": "^0.23.11",
"socks": "^2.8.8",
"stringify-object": "^5.0.0",
"ts-morph": "^26.0.0",
"tsconfig-paths": "^4.2.0",
"undici": "^7.29.0",
"validate-npm-package-name": "^7.0.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.6"
},
"devDependencies": {
"@antfu/ni": "^25.0.0",
"@types/babel__core": "^7.20.5",
"@types/fs-extra": "^11.0.4",
"@types/prompts": "^2.4.9",
"@types/stringify-object": "^4.0.5",
"msw": "^2.10.4",
"rimraf": "^6.0.1",
"tsup": "^8.5.0",
"type-fest": "^4.41.0",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,470 @@
export const TAILWIND_COLOR_SCALES = [
"50",
"100",
"200",
"300",
"400",
"500",
"600",
"700",
"800",
"900",
"950",
] as const
export const TAILWIND_COLOR_FAMILIES = [
"red",
"orange",
"amber",
"yellow",
"lime",
"green",
"emerald",
"teal",
"cyan",
"sky",
"blue",
"indigo",
"violet",
"purple",
"fuchsia",
"pink",
"rose",
"slate",
"gray",
"zinc",
"neutral",
"stone",
"mauve",
"olive",
"mist",
"taupe",
] as const
export type TailwindColorScale = (typeof TAILWIND_COLOR_SCALES)[number]
export type TailwindColorFamily = (typeof TAILWIND_COLOR_FAMILIES)[number]
export const TAILWIND_COLORS: Record<
TailwindColorFamily,
Record<TailwindColorScale, string>
> = {
red: {
"50": "oklch(97.1% 0.013 17.38)",
"100": "oklch(93.6% 0.032 17.717)",
"200": "oklch(88.5% 0.062 18.334)",
"300": "oklch(80.8% 0.114 19.571)",
"400": "oklch(70.4% 0.191 22.216)",
"500": "oklch(63.7% 0.237 25.331)",
"600": "oklch(57.7% 0.245 27.325)",
"700": "oklch(50.5% 0.213 27.518)",
"800": "oklch(44.4% 0.177 26.899)",
"900": "oklch(39.6% 0.141 25.723)",
"950": "oklch(25.8% 0.092 26.042)",
},
orange: {
"50": "oklch(98% 0.016 73.684)",
"100": "oklch(95.4% 0.038 75.164)",
"200": "oklch(90.1% 0.076 70.697)",
"300": "oklch(83.7% 0.128 66.29)",
"400": "oklch(75% 0.183 55.934)",
"500": "oklch(70.5% 0.213 47.604)",
"600": "oklch(64.6% 0.222 41.116)",
"700": "oklch(55.3% 0.195 38.402)",
"800": "oklch(47% 0.157 37.304)",
"900": "oklch(40.8% 0.123 38.172)",
"950": "oklch(26.6% 0.079 36.259)",
},
amber: {
"50": "oklch(98.7% 0.022 95.277)",
"100": "oklch(96.2% 0.059 95.617)",
"200": "oklch(92.4% 0.12 95.746)",
"300": "oklch(87.9% 0.169 91.605)",
"400": "oklch(82.8% 0.189 84.429)",
"500": "oklch(76.9% 0.188 70.08)",
"600": "oklch(66.6% 0.179 58.318)",
"700": "oklch(55.5% 0.163 48.998)",
"800": "oklch(47.3% 0.137 46.201)",
"900": "oklch(41.4% 0.112 45.904)",
"950": "oklch(27.9% 0.077 45.635)",
},
yellow: {
"50": "oklch(98.7% 0.026 102.212)",
"100": "oklch(97.3% 0.071 103.193)",
"200": "oklch(94.5% 0.129 101.54)",
"300": "oklch(90.5% 0.182 98.111)",
"400": "oklch(85.2% 0.199 91.936)",
"500": "oklch(79.5% 0.184 86.047)",
"600": "oklch(68.1% 0.162 75.834)",
"700": "oklch(55.4% 0.135 66.442)",
"800": "oklch(47.6% 0.114 61.907)",
"900": "oklch(42.1% 0.095 57.708)",
"950": "oklch(28.6% 0.066 53.813)",
},
lime: {
"50": "oklch(98.6% 0.031 120.757)",
"100": "oklch(96.7% 0.067 122.328)",
"200": "oklch(93.8% 0.127 124.321)",
"300": "oklch(89.7% 0.196 126.665)",
"400": "oklch(84.1% 0.238 128.85)",
"500": "oklch(76.8% 0.233 130.85)",
"600": "oklch(64.8% 0.2 131.684)",
"700": "oklch(53.2% 0.157 131.589)",
"800": "oklch(45.3% 0.124 130.933)",
"900": "oklch(40.5% 0.101 131.063)",
"950": "oklch(27.4% 0.072 132.109)",
},
green: {
"50": "oklch(98.2% 0.018 155.826)",
"100": "oklch(96.2% 0.044 156.743)",
"200": "oklch(92.5% 0.084 155.995)",
"300": "oklch(87.1% 0.15 154.449)",
"400": "oklch(79.2% 0.209 151.711)",
"500": "oklch(72.3% 0.219 149.579)",
"600": "oklch(62.7% 0.194 149.214)",
"700": "oklch(52.7% 0.154 150.069)",
"800": "oklch(44.8% 0.119 151.328)",
"900": "oklch(39.3% 0.095 152.535)",
"950": "oklch(26.6% 0.065 152.934)",
},
emerald: {
"50": "oklch(97.9% 0.021 166.113)",
"100": "oklch(95% 0.052 163.051)",
"200": "oklch(90.5% 0.093 164.15)",
"300": "oklch(84.5% 0.143 164.978)",
"400": "oklch(76.5% 0.177 163.223)",
"500": "oklch(69.6% 0.17 162.48)",
"600": "oklch(59.6% 0.145 163.225)",
"700": "oklch(50.8% 0.118 165.612)",
"800": "oklch(43.2% 0.095 166.913)",
"900": "oklch(37.8% 0.077 168.94)",
"950": "oklch(26.2% 0.051 172.552)",
},
teal: {
"50": "oklch(98.4% 0.014 180.72)",
"100": "oklch(95.3% 0.051 180.801)",
"200": "oklch(91% 0.096 180.426)",
"300": "oklch(85.5% 0.138 181.071)",
"400": "oklch(77.7% 0.152 181.912)",
"500": "oklch(70.4% 0.14 182.503)",
"600": "oklch(60% 0.118 184.704)",
"700": "oklch(51.1% 0.096 186.391)",
"800": "oklch(43.7% 0.078 188.216)",
"900": "oklch(38.6% 0.063 188.416)",
"950": "oklch(27.7% 0.046 192.524)",
},
cyan: {
"50": "oklch(98.4% 0.019 200.873)",
"100": "oklch(95.6% 0.045 203.388)",
"200": "oklch(91.7% 0.08 205.041)",
"300": "oklch(86.5% 0.127 207.078)",
"400": "oklch(78.9% 0.154 211.53)",
"500": "oklch(71.5% 0.143 215.221)",
"600": "oklch(60.9% 0.126 221.723)",
"700": "oklch(52% 0.105 223.128)",
"800": "oklch(45% 0.085 224.283)",
"900": "oklch(39.8% 0.07 227.392)",
"950": "oklch(30.2% 0.056 229.695)",
},
sky: {
"50": "oklch(97.7% 0.013 236.62)",
"100": "oklch(95.1% 0.026 236.824)",
"200": "oklch(90.1% 0.058 230.902)",
"300": "oklch(82.8% 0.111 230.318)",
"400": "oklch(74.6% 0.16 232.661)",
"500": "oklch(68.5% 0.169 237.323)",
"600": "oklch(58.8% 0.158 241.966)",
"700": "oklch(50% 0.134 242.749)",
"800": "oklch(44.3% 0.11 240.79)",
"900": "oklch(39.1% 0.09 240.876)",
"950": "oklch(29.3% 0.066 243.157)",
},
blue: {
"50": "oklch(97% 0.014 254.604)",
"100": "oklch(93.2% 0.032 255.585)",
"200": "oklch(88.2% 0.059 254.128)",
"300": "oklch(80.9% 0.105 251.813)",
"400": "oklch(70.7% 0.165 254.624)",
"500": "oklch(62.3% 0.214 259.815)",
"600": "oklch(54.6% 0.245 262.881)",
"700": "oklch(48.8% 0.243 264.376)",
"800": "oklch(42.4% 0.199 265.638)",
"900": "oklch(37.9% 0.146 265.522)",
"950": "oklch(28.2% 0.091 267.935)",
},
indigo: {
"50": "oklch(96.2% 0.018 272.314)",
"100": "oklch(93% 0.034 272.788)",
"200": "oklch(87% 0.065 274.039)",
"300": "oklch(78.5% 0.115 274.713)",
"400": "oklch(67.3% 0.182 276.935)",
"500": "oklch(58.5% 0.233 277.117)",
"600": "oklch(51.1% 0.262 276.966)",
"700": "oklch(45.7% 0.24 277.023)",
"800": "oklch(39.8% 0.195 277.366)",
"900": "oklch(35.9% 0.144 278.697)",
"950": "oklch(25.7% 0.09 281.288)",
},
violet: {
"50": "oklch(96.9% 0.016 293.756)",
"100": "oklch(94.3% 0.029 294.588)",
"200": "oklch(89.4% 0.057 293.283)",
"300": "oklch(81.1% 0.111 293.571)",
"400": "oklch(70.2% 0.183 293.541)",
"500": "oklch(60.6% 0.25 292.717)",
"600": "oklch(54.1% 0.281 293.009)",
"700": "oklch(49.1% 0.27 292.581)",
"800": "oklch(43.2% 0.232 292.759)",
"900": "oklch(38% 0.189 293.745)",
"950": "oklch(28.3% 0.141 291.089)",
},
purple: {
"50": "oklch(97.7% 0.014 308.299)",
"100": "oklch(94.6% 0.033 307.174)",
"200": "oklch(90.2% 0.063 306.703)",
"300": "oklch(82.7% 0.119 306.383)",
"400": "oklch(71.4% 0.203 305.504)",
"500": "oklch(62.7% 0.265 303.9)",
"600": "oklch(55.8% 0.288 302.321)",
"700": "oklch(49.6% 0.265 301.924)",
"800": "oklch(43.8% 0.218 303.724)",
"900": "oklch(38.1% 0.176 304.987)",
"950": "oklch(29.1% 0.149 302.717)",
},
fuchsia: {
"50": "oklch(97.7% 0.017 320.058)",
"100": "oklch(95.2% 0.037 318.852)",
"200": "oklch(90.3% 0.076 319.62)",
"300": "oklch(83.3% 0.145 321.434)",
"400": "oklch(74% 0.238 322.16)",
"500": "oklch(66.7% 0.295 322.15)",
"600": "oklch(59.1% 0.293 322.896)",
"700": "oklch(51.8% 0.253 323.949)",
"800": "oklch(45.2% 0.211 324.591)",
"900": "oklch(40.1% 0.17 325.612)",
"950": "oklch(29.3% 0.136 325.661)",
},
pink: {
"50": "oklch(97.1% 0.014 343.198)",
"100": "oklch(94.8% 0.028 342.258)",
"200": "oklch(89.9% 0.061 343.231)",
"300": "oklch(82.3% 0.12 346.018)",
"400": "oklch(71.8% 0.202 349.761)",
"500": "oklch(65.6% 0.241 354.308)",
"600": "oklch(59.2% 0.249 0.584)",
"700": "oklch(52.5% 0.223 3.958)",
"800": "oklch(45.9% 0.187 3.815)",
"900": "oklch(40.8% 0.153 2.432)",
"950": "oklch(28.4% 0.109 3.907)",
},
rose: {
"50": "oklch(96.9% 0.015 12.422)",
"100": "oklch(94.1% 0.03 12.58)",
"200": "oklch(89.2% 0.058 10.001)",
"300": "oklch(81% 0.117 11.638)",
"400": "oklch(71.2% 0.194 13.428)",
"500": "oklch(64.5% 0.246 16.439)",
"600": "oklch(58.6% 0.253 17.585)",
"700": "oklch(51.4% 0.222 16.935)",
"800": "oklch(45.5% 0.188 13.697)",
"900": "oklch(41% 0.159 10.272)",
"950": "oklch(27.1% 0.105 12.094)",
},
slate: {
"50": "oklch(98.4% 0.003 247.858)",
"100": "oklch(96.8% 0.007 247.896)",
"200": "oklch(92.9% 0.013 255.508)",
"300": "oklch(86.9% 0.022 252.894)",
"400": "oklch(70.4% 0.04 256.788)",
"500": "oklch(55.4% 0.046 257.417)",
"600": "oklch(44.6% 0.043 257.281)",
"700": "oklch(37.2% 0.044 257.287)",
"800": "oklch(27.9% 0.041 260.031)",
"900": "oklch(20.8% 0.042 265.755)",
"950": "oklch(12.9% 0.042 264.695)",
},
gray: {
"50": "oklch(98.5% 0.002 247.839)",
"100": "oklch(96.7% 0.003 264.542)",
"200": "oklch(92.8% 0.006 264.531)",
"300": "oklch(87.2% 0.01 258.338)",
"400": "oklch(70.7% 0.022 261.325)",
"500": "oklch(55.1% 0.027 264.364)",
"600": "oklch(44.6% 0.03 256.802)",
"700": "oklch(37.3% 0.034 259.733)",
"800": "oklch(27.8% 0.033 256.848)",
"900": "oklch(21% 0.034 264.665)",
"950": "oklch(13% 0.028 261.692)",
},
zinc: {
"50": "oklch(98.5% 0 0)",
"100": "oklch(96.7% 0.001 286.375)",
"200": "oklch(92% 0.004 286.32)",
"300": "oklch(87.1% 0.006 286.286)",
"400": "oklch(70.5% 0.015 286.067)",
"500": "oklch(55.2% 0.016 285.938)",
"600": "oklch(44.2% 0.017 285.786)",
"700": "oklch(37% 0.013 285.805)",
"800": "oklch(27.4% 0.006 286.033)",
"900": "oklch(21% 0.006 285.885)",
"950": "oklch(14.1% 0.005 285.823)",
},
neutral: {
"50": "oklch(98.5% 0 0)",
"100": "oklch(97% 0 0)",
"200": "oklch(92.2% 0 0)",
"300": "oklch(87% 0 0)",
"400": "oklch(70.8% 0 0)",
"500": "oklch(55.6% 0 0)",
"600": "oklch(43.9% 0 0)",
"700": "oklch(37.1% 0 0)",
"800": "oklch(26.9% 0 0)",
"900": "oklch(20.5% 0 0)",
"950": "oklch(14.5% 0 0)",
},
stone: {
"50": "oklch(98.5% 0.001 106.423)",
"100": "oklch(97% 0.001 106.424)",
"200": "oklch(92.3% 0.003 48.717)",
"300": "oklch(86.9% 0.005 56.366)",
"400": "oklch(70.9% 0.01 56.259)",
"500": "oklch(55.3% 0.013 58.071)",
"600": "oklch(44.4% 0.011 73.639)",
"700": "oklch(37.4% 0.01 67.558)",
"800": "oklch(26.8% 0.007 34.298)",
"900": "oklch(21.6% 0.006 56.043)",
"950": "oklch(14.7% 0.004 49.25)",
},
mauve: {
"50": "oklch(98.5% 0 0)",
"100": "oklch(96% 0.003 325.6)",
"200": "oklch(92.2% 0.005 325.62)",
"300": "oklch(86.5% 0.012 325.68)",
"400": "oklch(71.1% 0.019 323.02)",
"500": "oklch(54.2% 0.034 322.5)",
"600": "oklch(43.5% 0.029 321.78)",
"700": "oklch(36.4% 0.029 323.89)",
"800": "oklch(26.3% 0.024 320.12)",
"900": "oklch(21.2% 0.019 322.12)",
"950": "oklch(14.5% 0.008 326)",
},
olive: {
"50": "oklch(98.8% 0.003 106.5)",
"100": "oklch(96.6% 0.005 106.5)",
"200": "oklch(93% 0.007 106.5)",
"300": "oklch(88% 0.011 106.6)",
"400": "oklch(73.7% 0.021 106.9)",
"500": "oklch(58% 0.031 107.3)",
"600": "oklch(46.6% 0.025 107.3)",
"700": "oklch(39.4% 0.023 107.4)",
"800": "oklch(28.6% 0.016 107.4)",
"900": "oklch(22.8% 0.013 107.4)",
"950": "oklch(15.3% 0.006 107.1)",
},
mist: {
"50": "oklch(98.7% 0.002 197.1)",
"100": "oklch(96.3% 0.002 197.1)",
"200": "oklch(92.5% 0.005 214.3)",
"300": "oklch(87.2% 0.007 219.6)",
"400": "oklch(72.3% 0.014 214.4)",
"500": "oklch(56% 0.021 213.5)",
"600": "oklch(45% 0.017 213.2)",
"700": "oklch(37.8% 0.015 216)",
"800": "oklch(27.5% 0.011 216.9)",
"900": "oklch(21.8% 0.008 223.9)",
"950": "oklch(14.8% 0.004 228.8)",
},
taupe: {
"50": "oklch(98.6% 0.002 67.8)",
"100": "oklch(96% 0.002 17.2)",
"200": "oklch(92.2% 0.005 34.3)",
"300": "oklch(86.8% 0.007 39.5)",
"400": "oklch(71.4% 0.014 41.2)",
"500": "oklch(54.7% 0.021 43.1)",
"600": "oklch(43.8% 0.017 39.3)",
"700": "oklch(36.7% 0.016 35.7)",
"800": "oklch(26.8% 0.011 36.5)",
"900": "oklch(21.4% 0.009 43.1)",
"950": "oklch(14.7% 0.004 49.3)",
},
}
// Old preset codes only carried one theme value, so these legacy chart HSLs
// should resolve to a neutral fallback family instead of a separate theme.
const LEGACY_COLOR_FAMILY_ALIASES: Record<string, TailwindColorFamily> = {
"220.9 39.3% 11%": "gray",
"210 20% 98%": "gray",
"12 76% 61%": "gray",
"220 70% 50%": "gray",
}
const TAILWIND_COLOR_VALUE_TO_FAMILY = new Map<string, TailwindColorFamily>()
for (const family of TAILWIND_COLOR_FAMILIES) {
for (const scale of TAILWIND_COLOR_SCALES) {
TAILWIND_COLOR_VALUE_TO_FAMILY.set(
normalizeColorValue(TAILWIND_COLORS[family][scale]),
family
)
}
}
export function findTailwindColorFamily(value: string | undefined) {
const normalized = normalizeColorValue(value)
if (!normalized) {
return null
}
return (
TAILWIND_COLOR_VALUE_TO_FAMILY.get(normalized) ??
LEGACY_COLOR_FAMILY_ALIASES[normalized] ??
null
)
}
export function normalizeColorValue(value: string | undefined) {
if (!value) {
return ""
}
const normalized = value.trim().replace(/\s+/g, " ").toLowerCase()
if (!normalized.startsWith("oklch(") || !normalized.endsWith(")")) {
return normalized
}
const inner = normalized.slice(6, -1).trim()
const [color] = inner.split(/\s*\/\s*/)
const parts = color.split(/\s+/)
if (parts.length < 3) {
return normalized
}
const lightness = normalizeColorNumber(parts[0], { percentage: true })
const chroma = normalizeColorNumber(parts[1])
const hue = normalizeColorNumber(parts[2])
return `oklch(${lightness} ${chroma} ${hue})`
}
function normalizeColorNumber(
value: string,
options: {
percentage?: boolean
} = {}
) {
if (options.percentage && value.endsWith("%")) {
return formatColorNumber(Number.parseFloat(value) / 100)
}
return formatColorNumber(Number.parseFloat(value))
}
function formatColorNumber(value: number) {
if (Number.isNaN(value)) {
return ""
}
return Number(value.toFixed(12)).toString()
}

View File

@@ -0,0 +1,739 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { REGISTRY_URL, SHADCN_URL } from "@/src/registry/constants"
import { getFixturesDir, withTempDir } from "@/src/test-helpers"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { dryRunComponents } from "@/src/utils/dry-run"
import { updateAppIndex } from "@/src/utils/update-app-index"
import fs from "fs-extra"
import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
import prompts from "prompts"
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest"
import { add } from "./add"
// --- Mocked seams (the collaborators add.ts delegates to). ---
vi.mock("@/src/utils/add-components", () => ({
addComponents: vi.fn(),
}))
vi.mock("@/src/commands/init", () => ({
runInit: vi.fn(),
}))
vi.mock("@/src/utils/create-project", () => ({
createProject: vi.fn(),
}))
vi.mock("@/src/utils/update-app-index", () => ({
updateAppIndex: vi.fn(),
}))
vi.mock("@/src/utils/dry-run", () => ({
dryRunComponents: vi.fn(),
}))
vi.mock("prompts", () => ({
default: vi.fn(),
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
stop: vi.fn(),
succeed: vi.fn(),
fail: vi.fn(),
info: vi.fn(),
})),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn((error) => {
throw error
}),
}))
// --- MSW registry server. ---
//
// Serves a minimal registry:ui item for most component names, plus
// dedicated handlers for the registry:style, registry:theme and
// universal (registry:item) branches, the /init endpoint hit by
// resolveRegistryBaseConfig, and a direct-URL v0 "chat" item for the
// updateAppIndex branch.
function uiItem(name: string) {
return {
name,
type: "registry:ui",
files: [
{
path: `registry/new-york/ui/${name}.tsx`,
type: "registry:ui",
content: `export function ${name}() {}`,
},
],
}
}
const CHAT_ITEM_URL = "https://v0.dev/chat/b/xyz"
// Registered under both "new-york" (used by fixtures with a non-empty
// tailwind.config, e.g. config-full) and "new-york-v4" (the fallback style
// resolved by configWithDefaults whenever tailwind.config is "", which is
// both createConfig's default and the vite-with-tailwind fixture's value).
const STYLES = ["new-york", "new-york-v4", "base-nova"]
function registerItem(name: string, body: Record<string, unknown>) {
return STYLES.map((style) =>
http.get(`${REGISTRY_URL}/styles/${style}/${name}.json`, () =>
HttpResponse.json(body)
)
)
}
const server = setupServer(
http.get(`${REGISTRY_URL}/index.json`, () =>
HttpResponse.json([
uiItem("button"),
uiItem("sonner"),
uiItem("toast"),
uiItem("toaster"),
])
),
...registerItem("button", uiItem("button")),
...registerItem("card", uiItem("card")),
...registerItem("sonner", uiItem("sonner")),
...registerItem("toast", uiItem("toast")),
...registerItem("toaster", uiItem("toaster")),
...registerItem("style-item", {
name: "style-item",
type: "registry:style",
}),
...registerItem("theme-item", {
name: "theme-item",
type: "registry:theme",
}),
...registerItem("universal-item", {
name: "universal-item",
type: "registry:item",
}),
http.get(`${SHADCN_URL}/init`, () =>
HttpResponse.json({
name: "base",
type: "registry:base",
extends: "none",
})
),
// resolveRegistryUrl appends "/json" for /chat/b/ URLs (the v0 registry
// convention), so the handler must match the rewritten address.
http.get(`${CHAT_ITEM_URL}/json`, () =>
HttpResponse.json({
name: "chat-item",
type: "registry:ui",
files: [],
})
)
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// --- Test helpers. ---
async function withFixtureCopy<T>(
fixtureName: string,
fn: (dir: string) => Promise<T>
) {
return withTempDir(async (dir) => {
await fs.copy(getFixturesDir(fixtureName), dir)
return fn(dir)
})
}
async function runAdd(args: string[], cwd: string) {
return add.parseAsync(["node", "shadcn", ...args, "--cwd", cwd], {
from: "node",
})
}
async function setConfigStyle(cwd: string, style: string) {
const configPath = path.join(cwd, "components.json")
const config = await fs.readJson(configPath)
await fs.writeJson(configPath, {
...config,
style,
})
}
function mockProcessExit() {
return vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`)
}) as any
}
function mockPrompts(answers: Record<string, unknown>) {
vi.mocked(prompts).mockImplementation(async (opts: any) => {
const name = Array.isArray(opts) ? opts[0]?.name : opts?.name
if (name && name in answers) {
return { [name]: answers[name] }
}
return {}
})
}
function baseConfig(cwd: string) {
return {
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "",
baseColor: "neutral",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "@/components",
ui: "@/components/ui",
hooks: "@/hooks",
lib: "@/lib",
utils: "@/lib/utils",
},
registries: {},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: "",
utils: "",
components: "",
lib: "",
hooks: "",
ui: "",
},
} as any
}
describe("add command", () => {
let exit: ReturnType<typeof mockProcessExit>
beforeEach(() => {
vi.clearAllMocks()
exit = mockProcessExit()
mockPrompts({})
})
afterEach(() => {
exit.mockRestore()
})
describe("happy path", () => {
it("installs components via addComponents when a valid project exists", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["button"], cwd)
expect(addComponents).toHaveBeenCalledTimes(1)
expect(addComponents).toHaveBeenCalledWith(
["button"],
expect.any(Object),
expect.any(Object)
)
expect(runInit).not.toHaveBeenCalled()
expect(createProject).not.toHaveBeenCalled()
})
})
})
describe("dry run", () => {
beforeEach(() => {
vi.mocked(dryRunComponents).mockResolvedValue({
files: [],
dependencies: [],
devDependencies: [],
css: null,
envVars: null,
fonts: [],
docs: null,
})
})
it("previews with --dry-run and never calls addComponents", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["button", "--dry-run"], cwd)
expect(dryRunComponents).toHaveBeenCalledTimes(1)
expect(addComponents).not.toHaveBeenCalled()
})
})
it("implies dry-run with --diff", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["button", "--diff"], cwd)
expect(dryRunComponents).toHaveBeenCalledTimes(1)
expect(addComponents).not.toHaveBeenCalled()
})
})
it("implies dry-run with --view", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["button", "--view"], cwd)
expect(dryRunComponents).toHaveBeenCalledTimes(1)
expect(addComponents).not.toHaveBeenCalled()
})
})
})
describe("missing components.json (init flow)", () => {
it("runs init with the documented flags when the user proceeds", async () => {
await withTempDir(async (cwd) => {
await fs.writeJson(cwd + "/package.json", {
name: "no-config-project",
version: "1.0.0",
})
mockPrompts({
proceed: true,
base: "radix",
selectedPreset: "nova",
})
vi.mocked(runInit).mockResolvedValue(baseConfig(cwd))
await runAdd(["button"], cwd)
expect(runInit).toHaveBeenCalledTimes(1)
const callArgs = vi.mocked(runInit).mock.calls[0][0]
expect(callArgs.cwd).toBe(cwd)
expect(callArgs.yes).toBe(true)
expect(callArgs.force).toBe(true)
expect(callArgs.isNewProject).toBe(false)
expect(callArgs.skipPreflight).toBe(false)
// initHasRun guard: addComponents must NOT run again after init.
expect(addComponents).not.toHaveBeenCalled()
})
})
it("exits 1 without running init when the user declines", async () => {
await withTempDir(async (cwd) => {
await fs.writeJson(cwd + "/package.json", {
name: "no-config-project",
version: "1.0.0",
})
mockPrompts({ proceed: false })
await expect(runAdd(["button"], cwd)).rejects.toThrow("process.exit:1")
expect(runInit).not.toHaveBeenCalled()
})
})
it("allows toast after selecting Base UI", async () => {
await withTempDir(async (cwd) => {
await fs.writeJson(path.join(cwd, "package.json"), {
name: "no-config-project",
version: "1.0.0",
})
mockPrompts({
proceed: true,
base: "base",
selectedPreset: "nova",
})
vi.mocked(runInit).mockResolvedValue({
...baseConfig(cwd),
style: "base-nova",
})
await runAdd(["toast"], cwd)
expect(runInit).toHaveBeenCalledWith(
expect.objectContaining({
components: expect.arrayContaining(["toast"]),
})
)
})
})
it("warns about toast after selecting a non-Base UI library", async () => {
await withTempDir(async (cwd) => {
await fs.writeJson(path.join(cwd, "package.json"), {
name: "no-config-project",
version: "1.0.0",
})
const { logger } = await import("@/src/utils/logger")
mockPrompts({
proceed: true,
base: "radix",
})
await expect(runAdd(["toast"], cwd)).rejects.toThrow("process.exit:1")
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("only available for Base UI projects")
)
expect(runInit).not.toHaveBeenCalled()
})
})
})
describe("empty/missing project (create-project flow)", () => {
it("does not scaffold before warning about toast for a non-Base UI library", async () => {
await withTempDir(async (emptyDir) => {
const { logger } = await import("@/src/utils/logger")
mockPrompts({
base: "radix",
})
await expect(runAdd(["toast"], emptyDir)).rejects.toThrow(
"process.exit:1"
)
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("only available for Base UI projects")
)
expect(createProject).not.toHaveBeenCalled()
expect(runInit).not.toHaveBeenCalled()
})
})
it("runs init with isNewProject/skipPreflight after scaffolding a project", async () => {
await withTempDir(async (emptyDir) => {
await withTempDir(async (projectPath) => {
mockPrompts({
base: "radix",
selectedPreset: "nova",
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "app",
template: "next",
} as any)
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
await runAdd(["button"], emptyDir)
expect(createProject).toHaveBeenCalledTimes(1)
expect(runInit).toHaveBeenCalledTimes(1)
const callArgs = vi.mocked(runInit).mock.calls[0][0]
expect(callArgs.cwd).toBe(projectPath)
expect(callArgs.yes).toBe(true)
expect(callArgs.force).toBe(true)
expect(callArgs.isNewProject).toBe(true)
expect(callArgs.skipPreflight).toBe(true)
expect(addComponents).not.toHaveBeenCalled()
})
})
})
it("exits 1 when createProject cannot resolve a project path", async () => {
await withTempDir(async (emptyDir) => {
vi.mocked(createProject).mockResolvedValue({
projectPath: null,
projectName: null,
template: "next",
} as any)
await expect(runAdd(["button"], emptyDir)).rejects.toThrow(
"process.exit:1"
)
expect(runInit).not.toHaveBeenCalled()
})
})
it("updates the app index for a single v0 chat component", async () => {
await withTempDir(async (emptyDir) => {
await withTempDir(async (projectPath) => {
mockPrompts({
base: "radix",
selectedPreset: "nova",
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "app",
template: "next",
} as any)
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
await runAdd([CHAT_ITEM_URL], emptyDir)
expect(updateAppIndex).toHaveBeenCalledTimes(1)
expect(updateAppIndex).toHaveBeenCalledWith(
CHAT_ITEM_URL,
expect.any(Object)
)
})
})
})
it("does not update the app index for multiple components", async () => {
await withTempDir(async (emptyDir) => {
await withTempDir(async (projectPath) => {
mockPrompts({
base: "radix",
selectedPreset: "nova",
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "app",
template: "next",
} as any)
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
await runAdd(["button", "card"], emptyDir)
expect(updateAppIndex).not.toHaveBeenCalled()
})
})
})
it("does not update the app index for a non-matching single component", async () => {
await withTempDir(async (emptyDir) => {
await withTempDir(async (projectPath) => {
mockPrompts({
base: "radix",
selectedPreset: "nova",
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "app",
template: "next",
} as any)
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
await runAdd(["button"], emptyDir)
expect(updateAppIndex).not.toHaveBeenCalled()
})
})
})
})
describe("registry:style / registry:theme overwrite confirm", () => {
it("exits 1 when the user declines the overwrite confirm", async () => {
await withTempDir(async (cwd) => {
mockPrompts({ confirm: false })
await expect(runAdd(["style-item"], cwd)).rejects.toThrow(
"process.exit:1"
)
expect(addComponents).not.toHaveBeenCalled()
})
})
it("proceeds when the user confirms the overwrite", async () => {
await withFixtureCopy("config-full", async (cwd) => {
mockPrompts({ confirm: true })
await runAdd(["style-item"], cwd)
expect(addComponents).toHaveBeenCalledTimes(1)
expect(addComponents).toHaveBeenCalledWith(
["style-item"],
expect.any(Object),
expect.any(Object)
)
})
})
it("skips the confirm prompt entirely with --yes", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["style-item", "--yes"], cwd)
expect(prompts).not.toHaveBeenCalled()
expect(addComponents).toHaveBeenCalledTimes(1)
})
})
it("also prompts for registry:theme items", async () => {
await withTempDir(async (cwd) => {
mockPrompts({ confirm: false })
await expect(runAdd(["theme-item"], cwd)).rejects.toThrow(
"process.exit:1"
)
expect(addComponents).not.toHaveBeenCalled()
})
})
})
describe("universal registry item short-circuit", () => {
it("calls addComponents directly without running preflight/init", async () => {
await withTempDir(async (cwd) => {
await runAdd(["universal-item"], cwd)
expect(addComponents).toHaveBeenCalledTimes(1)
expect(addComponents).toHaveBeenCalledWith(
["universal-item"],
expect.any(Object),
expect.any(Object)
)
expect(runInit).not.toHaveBeenCalled()
expect(prompts).not.toHaveBeenCalled()
})
})
})
describe("deprecated components guard (Tailwind v4)", () => {
it("exits 1 and warns when adding toaster", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
const { logger } = await import("@/src/utils/logger")
await expect(runAdd(["toaster"], cwd)).rejects.toThrow("process.exit:1")
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("toaster component is deprecated")
)
expect(addComponents).not.toHaveBeenCalled()
})
})
it("exits 1 and warns when adding toast to a legacy style", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
const { logger } = await import("@/src/utils/logger")
await expect(runAdd(["toast"], cwd)).rejects.toThrow("process.exit:1")
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("only available for Base UI projects")
)
expect(addComponents).not.toHaveBeenCalled()
})
})
it.each(["radix-nova", "aria-nova"])(
"exits 1 and warns when adding toast to %s",
async (style) => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
const { logger } = await import("@/src/utils/logger")
await setConfigStyle(cwd, style)
await expect(runAdd(["toast"], cwd)).rejects.toThrow("process.exit:1")
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("only available for Base UI projects")
)
expect(addComponents).not.toHaveBeenCalled()
})
}
)
it("allows adding toast to a Base UI style", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await setConfigStyle(cwd, "base-nova")
await runAdd(["toast"], cwd)
expect(addComponents).toHaveBeenCalledWith(
["toast"],
expect.objectContaining({ style: "base-nova" }),
expect.any(Object)
)
})
})
it("allows explicitly adding sonner to a Base UI style", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await setConfigStyle(cwd, "base-nova")
await runAdd(["sonner"], cwd)
expect(addComponents).toHaveBeenCalledWith(
["sonner"],
expect.objectContaining({ style: "base-nova" }),
expect.any(Object)
)
})
})
it("filters toast from --all for legacy styles", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await runAdd(["--all"], cwd)
expect(addComponents).toHaveBeenCalledWith(
["button", "sonner"],
expect.any(Object),
expect.any(Object)
)
})
})
it("includes toast in --all for Base UI styles", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await setConfigStyle(cwd, "base-nova")
await runAdd(["--all"], cwd)
expect(addComponents).toHaveBeenCalledWith(
["button", "toast"],
expect.objectContaining({ style: "base-nova" }),
expect.any(Object)
)
})
})
it.each([
{ style: "new-york-v4", includesToast: false },
{ style: "base-nova", includesToast: true },
])(
"filters the interactive picker for $style",
async ({ style, includesToast }) => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await setConfigStyle(cwd, style)
mockPrompts({ components: ["button"] })
await runAdd([], cwd)
const componentPrompt = vi
.mocked(prompts)
.mock.calls.map(([options]) => options)
.find(
(options) =>
!Array.isArray(options) && options.name === "components"
) as
| { choices?: Array<{ value: string }>; name?: string }
| undefined
const choices = (componentPrompt?.choices ?? []).map(
(choice) => choice.value
)
expect(componentPrompt).toBeDefined()
expect(choices.includes("toast")).toBe(includesToast)
expect(choices.includes("sonner")).toBe(!includesToast)
expect(choices).not.toContain("toaster")
})
}
)
})
})

View File

@@ -0,0 +1,430 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { preFlightAdd } from "@/src/preflights/preflight-add"
import { parsePresetStyle, type PresetBase } from "@/src/preset/preset"
import {
promptForBase,
promptForPreset,
resolveRegistryBaseConfig,
} from "@/src/preset/presets"
import { getRegistryItems, getShadcnRegistryIndex } from "@/src/registry/api"
import {
COMPONENTS_HIDDEN_FROM_SELECTION,
DEPRECATED_COMPONENTS,
} from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import { registryItemTypeSchema } from "@/src/registry/schema"
import { isUniversalRegistryItem } from "@/src/registry/utils"
import { getTemplateForFramework } from "@/src/templates/index"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { dryRunComponents } from "@/src/utils/dry-run"
import { formatDryRunResult } from "@/src/utils/dry-run-formatter"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import { createConfig, getConfig } from "@/src/utils/get-config"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { spinner } from "@/src/utils/spinner"
import { updateAppIndex } from "@/src/utils/update-app-index"
import { Command } from "commander"
import prompts from "prompts"
import { z } from "zod"
export const addOptionsSchema = z.object({
components: z.array(z.string()).optional(),
yes: z.boolean(),
overwrite: z.boolean(),
cwd: z.string(),
all: z.boolean(),
path: z.string().optional(),
silent: z.boolean(),
dryRun: z.boolean(),
diff: z.union([z.string(), z.literal(true)]).optional(),
view: z.union([z.string(), z.literal(true)]).optional(),
})
export const add = new Command()
.name("add")
.description("add a component to your project")
.argument("[components...]", "item addresses to add")
.option("-y, --yes", "skip confirmation prompt.", false)
.option("-o, --overwrite", "overwrite existing files.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-a, --all", "add all available components", false)
.option("-p, --path <path>", "the path to add the component to.")
.option("-s, --silent", "mute output.", false)
.option("--dry-run", "preview changes without writing files.", false)
.option("--diff [path]", "show diff for a file.")
.option("--view [path]", "show file contents.")
.action(async (components, opts) => {
try {
const options = addOptionsSchema.parse({
components,
...opts,
cwd: path.resolve(opts.cwd),
})
await loadEnvFiles(options.cwd)
const isDryRun = options.dryRun || options.diff || options.view
let initialConfig = await getConfig(options.cwd)
const hasExistingConfig = !!initialConfig
if (!initialConfig) {
initialConfig = createConfig({
style: "new-york",
resolvedPaths: {
cwd: options.cwd,
},
})
}
let hasNewRegistries = false
if (components.length > 0) {
const { config: updatedConfig, newRegistries } =
await ensureRegistriesInConfig(components, initialConfig, {
silent: options.silent,
writeFile: false,
})
initialConfig = updatedConfig
hasNewRegistries = newRegistries.length > 0
}
const projectInfo = await getProjectInfo(options.cwd)
const { base: configuredBase } = parsePresetStyle(initialConfig.style)
if (hasExistingConfig && projectInfo?.tailwindVersion === "v4") {
warnForDeprecatedComponents(components, configuredBase)
}
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
let shouldInstallStyleIndex = true
const shouldResolveInitialItem =
components.length > 0 &&
(hasExistingConfig ||
!DEPRECATED_COMPONENTS.some(
(component) => component.name === components[0]
))
if (shouldResolveInitialItem) {
const [registryItem] = await getRegistryItems([components[0]], {
config: initialConfig,
})
itemType = registryItem?.type
shouldInstallStyleIndex =
itemType !== "registry:theme" &&
itemType !== "registry:style" &&
itemType !== "registry:base"
if (isUniversalRegistryItem(registryItem) && !isDryRun) {
await addComponents(components, initialConfig, options)
return
}
if (
!options.yes &&
!isDryRun &&
(itemType === "registry:style" || itemType === "registry:theme")
) {
logger.break()
const { confirm } = await prompts({
type: "confirm",
name: "confirm",
message: highlighter.warn(
`You are about to install a new ${itemType.replace(
"registry:",
""
)}. \nExisting CSS variables and components will be overwritten. Continue?`
),
})
if (!confirm) {
logger.break()
logger.log(`Installation cancelled.`)
logger.break()
process.exit(1)
}
}
}
if (!options.components?.length) {
options.components = await promptForRegistryComponents(
options,
initialConfig.style
)
}
if (!components.length && projectInfo?.tailwindVersion === "v4") {
warnForDeprecatedComponents(options.components ?? [], configuredBase)
}
let { errors, config } = await preFlightAdd(options)
// No components.json file. Prompt the user to run init.
let initHasRun = false
if (errors[ERRORS.MISSING_CONFIG]) {
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: `You need to create a ${highlighter.info(
"components.json"
)} file to add components. Proceed?`,
initial: true,
})
if (!proceed) {
logger.break()
process.exit(1)
}
// Infer template from project framework.
const inferredTemplate = getTemplateForFramework(
projectInfo?.framework.name
)
// Prompt for base and preset.
const base = await promptForBase()
warnForDeprecatedComponents(options.components ?? [], base)
const { url: initUrl } = await promptForPreset({
rtl: false,
base,
template: inferredTemplate,
})
// Resolve registry:base config.
const {
registryBaseConfig,
installStyleIndex,
url: cleanInitUrl,
} = await resolveRegistryBaseConfig(initUrl, options.cwd)
config = await runInit({
cwd: options.cwd,
yes: true,
force: true,
defaults: false,
skipPreflight: false,
silent: options.silent && !hasNewRegistries,
isNewProject: false,
cssVariables: true,
rtl: false,
installStyleIndex,
components: [cleanInitUrl, ...(options.components ?? [])],
registryBaseConfig,
})
initHasRun = true
}
let shouldUpdateAppIndex = false
if (errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
const selectedBase = await promptForBase()
warnForDeprecatedComponents(options.components ?? [], selectedBase)
const { projectPath, template } = await createProject({
cwd: options.cwd,
force: options.overwrite,
components: options.components,
})
if (!projectPath) {
logger.break()
process.exit(1)
}
options.cwd = projectPath
const { url: initUrl } = await promptForPreset({
rtl: false,
base: selectedBase,
template,
})
const {
registryBaseConfig,
installStyleIndex,
url: cleanInitUrl,
} = await resolveRegistryBaseConfig(initUrl, options.cwd)
config = await runInit({
cwd: options.cwd,
yes: true,
force: true,
defaults: false,
skipPreflight: true,
silent: !hasNewRegistries && options.silent,
isNewProject: true,
cssVariables: true,
rtl: false,
installStyleIndex,
components: [cleanInitUrl, ...(options.components ?? [])],
registryBaseConfig,
})
initHasRun = true
shouldUpdateAppIndex =
options.components?.length === 1 &&
!!options.components[0].match(/\/chat\/b\//)
}
if (!config) {
throw new Error(
`Failed to read config at ${highlighter.info(options.cwd)}.`
)
}
const { config: updatedConfig } = await ensureRegistriesInConfig(
options.components,
config,
{
silent: options.silent || hasNewRegistries,
writeFile: !isDryRun,
}
)
config = updatedConfig
// Dry-run mode: preview changes without writing files.
// --diff and --view imply --dry-run.
if (isDryRun) {
const dryRunSpinner = spinner("Resolving items.", {
silent: options.silent,
}).start()
const dryRunResult = await dryRunComponents(
options.components,
config,
{
overwrite: options.overwrite,
}
)
dryRunSpinner.stop()
logger.log(
formatDryRunResult(dryRunResult, options.components, {
diff: options.diff,
view: options.view,
})
)
return
}
if (!initHasRun) {
await addComponents(options.components, config, options)
}
// If we're adding a single component and it's from the v0 registry,
// let's update the app/page.tsx file to import the component.
if (shouldUpdateAppIndex) {
await updateAppIndex(options.components[0], config)
}
} catch (error) {
logger.break()
handleError(error)
} finally {
clearRegistryContext()
}
})
async function promptForRegistryComponents(
options: z.infer<typeof addOptionsSchema>,
style: string
) {
const registryIndex = await getShadcnRegistryIndex()
if (!registryIndex) {
logger.break()
handleError(new Error("Failed to fetch registry index."))
return []
}
const { base } = parsePresetStyle(style)
if (options.all) {
return registryIndex
.map((entry) => entry.name)
.filter((component) => isComponentSelectable(component, base))
}
if (options.components?.length) {
return options.components
}
const { components } = await prompts({
type: "multiselect",
name: "components",
message: "Which components would you like to add?",
hint: "Space to select. A to toggle all. Enter to submit.",
instructions: false,
choices: registryIndex
.filter(
(entry) =>
entry.type === "registry:ui" &&
isComponentSelectable(entry.name, base)
)
.map((entry) => ({
title: entry.name,
value: entry.name,
selected: options.all ? true : options.components?.includes(entry.name),
})),
})
if (!components?.length) {
logger.warn("No components selected. Exiting.")
logger.info("")
process.exit(1)
}
const result = z.array(z.string()).safeParse(components)
if (!result.success) {
logger.error("")
handleError(new Error("Something went wrong. Please try again."))
return []
}
return result.data
}
function warnForDeprecatedComponents(
components: string[],
base: PresetBase | undefined
) {
const deprecatedComponents = DEPRECATED_COMPONENTS.filter(
(component) =>
components.includes(component.name) &&
isDeprecatedComponent(component.name, base)
)
if (!deprecatedComponents.length) {
return
}
logger.break()
deprecatedComponents.forEach((component) => {
logger.warn(highlighter.warn(component.message))
})
logger.break()
process.exit(1)
}
function isDeprecatedComponent(name: string, base: PresetBase | undefined) {
const component = DEPRECATED_COMPONENTS.find(
(component) => component.name === name
)
return (
!!component &&
(!component.availableIn || !base || !component.availableIn.includes(base))
)
}
function isComponentSelectable(name: string, base: PresetBase | undefined) {
if (isDeprecatedComponent(name, base)) {
return false
}
const component = COMPONENTS_HIDDEN_FROM_SELECTION.find(
(component) => component.name === name
)
return !component || !base || !component.hiddenIn.includes(base)
}

View File

@@ -0,0 +1,186 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import { describe, expect, it } from "vitest"
import {
getPresetUrlOnly,
parseApplyOnlyParts,
resolveApplyInitUrl,
resolveApplyOnly,
validateApplyOnlyPreset,
} from "./apply"
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
describe("resolveApplyInitUrl", () => {
it("should keep aria as the current base", () => {
const initUrl = resolveApplyInitUrl("b0", "aria")
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("base")).toBe("aria")
})
it("should include the inferred template for preset codes", () => {
const initUrl = resolveApplyInitUrl("a0", "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
expect(parsed.searchParams.get("template")).toBe("next")
expect(parsed.searchParams.get("preset")).toBe("a0")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should include the inferred template for named presets", () => {
const initUrl = resolveApplyInitUrl("lyra", "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
expect(parsed.searchParams.get("template")).toBe("next")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should keep the current base for raw preset URLs without injecting a template", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("template")).toBeNull()
expect(parsed.searchParams.get("track")).toBe("1")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should include only for preset codes", () => {
const initUrl = resolveApplyInitUrl("a0", "base", {
template: "next",
rtl: true,
only: "theme,font",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("theme,font")
})
it("should include only for named presets", () => {
const initUrl = resolveApplyInitUrl("lyra", "base", {
template: "next",
rtl: true,
only: "font",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("font")
})
it("should include only for raw preset URLs", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
rtl: true,
only: "theme",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("theme")
})
it("should preserve only from raw preset URLs", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default&only=font`
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("font")
})
})
describe("parseApplyOnlyParts", () => {
it("returns undefined when only is omitted", () => {
expect(resolveApplyOnly(undefined)).toBeUndefined()
})
it("rejects missing only values with allowed values", () => {
expect(() => resolveApplyOnly(true)).toThrow(
[
"Missing value for --only.",
"Use one or more of: theme, font.",
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
})
it("normalizes explicit values and aliases", () => {
expect(resolveApplyOnly("font")).toEqual(["font"])
expect(parseApplyOnlyParts("theme,font")).toEqual(["theme", "font"])
})
it("dedupes and accepts plural aliases", () => {
expect(parseApplyOnlyParts("theme,fonts,font")).toEqual(["theme", "font"])
})
it("rejects invalid values", () => {
expect(() => parseApplyOnlyParts("theme,colors")).toThrow(
[
"Invalid value for --only: theme,colors.",
"Use one or more of: theme, font.",
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
expect(() => parseApplyOnlyParts("")).toThrow("Invalid value for --only")
expect(() => parseApplyOnlyParts("icon")).toThrow(
"Use one or more of: theme, font."
)
})
})
describe("getPresetUrlOnly", () => {
it("reads only from shadcn init URLs", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&only=font`
expect(getPresetUrlOnly(presetUrl)).toBe("font")
})
it("reads only from non-shadcn init URLs", () => {
const presetUrl =
"http://localhost:4000/init?base=radix&style=nova&only=font"
expect(getPresetUrlOnly(presetUrl)).toBe("font")
expect(resolveApplyOnly(getPresetUrlOnly(presetUrl))).toEqual(["font"])
})
it("ignores only on non-init URLs", () => {
const presetUrl =
"http://localhost:4000/r/styles/nova/button.json?only=font"
expect(getPresetUrlOnly(presetUrl)).toBeUndefined()
})
})
describe("validateApplyOnlyPreset", () => {
it("rejects only without a preset", () => {
expect(() => validateApplyOnlyPreset({ only: ["theme"] })).toThrow(
[
"Missing preset for --only.",
"Use: shadcn apply <preset> --only theme,font.",
].join("\n")
)
})
it("allows only with a preset", () => {
expect(() =>
validateApplyOnlyPreset({ preset: "a0", only: ["theme"] })
).not.toThrow()
})
})

View File

@@ -0,0 +1,601 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { preFlightApply } from "@/src/preflights/preflight-apply"
import {
decodePreset,
isPresetCode,
type PresetBase,
} from "@/src/preset/preset"
import {
DEFAULT_PRESETS,
promptToOpenPresetBuilder,
resolveCreateUrl,
resolveInitUrl,
resolveRegistryBaseConfig,
} from "@/src/preset/presets"
import { SHADCN_URL } from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import { registryConfigSchema } from "@/src/registry/schema"
import { isUrl } from "@/src/registry/utils"
import { getTemplateForFramework } from "@/src/templates/index"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import {
createFileBackup,
deleteFileBackup,
FileBackupError,
restoreFileBackup,
withFileBackup,
} from "@/src/utils/file-helper"
import {
getBase,
getWorkspaceConfig,
type Config,
} from "@/src/utils/get-config"
import {
getProjectComponents,
getProjectInfo,
} from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
import fs from "fs-extra"
import prompts from "prompts"
import { z } from "zod"
export const applyOptionsSchema = z.object({
cwd: z.string(),
positionalPreset: z.string().optional(),
preset: z.string().optional(),
only: z.union([z.boolean(), z.string()]).optional(),
yes: z.boolean(),
silent: z.boolean(),
})
const APPLY_ONLY_VALUES = ["theme", "font"] as const
type ApplyOnlyValue = (typeof APPLY_ONLY_VALUES)[number]
class ApplyOnlyError extends Error {
constructor(message: string) {
super(message)
this.name = "ApplyOnlyError"
}
}
class ApplyWorkspaceSyncError extends Error {
constructor(message: string) {
super(message)
this.name = "ApplyWorkspaceSyncError"
}
}
export const apply = new Command()
.name("apply")
.description("apply a preset to an existing project")
.argument("[preset]", "the preset to apply")
.option("--preset <preset>", "preset configuration to apply")
.option("--only [parts]", "apply only parts of a preset: theme, font")
.option("-y, --yes", "skip confirmation prompt.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-s, --silent", "mute output.", false)
.action(async (positionalPreset, opts) => {
try {
const options = applyOptionsSchema.parse({
...opts,
cwd: path.resolve(opts.cwd),
positionalPreset,
})
const preset = resolveApplyPreset(options)
const explicitOnly = resolveApplyOnly(options.only)
validateApplyOnlyPreset({ preset, only: explicitOnly })
const preflight = await preFlightApply(options)
if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
logger.break()
logger.error(
`The ${highlighter.info(
"apply"
)} command only works in an existing project.`
)
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
logger.break()
process.exit(1)
}
if (preflight.errors[ERRORS.MISSING_CONFIG]) {
logger.break()
logger.error(
`No ${highlighter.info("components.json")} found at ${highlighter.info(
options.cwd
)}.`
)
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
logger.break()
process.exit(1)
}
const existingConfig = preflight.config
if (!existingConfig) {
process.exit(1)
}
const rtl = existingConfig.rtl ?? false
const template = await resolveApplyTemplate(options.cwd)
if (!preset) {
const createUrl = resolveCreateUrl({
command: "init",
template,
base: getBase(existingConfig.style),
rtl,
})
await promptToOpenPresetBuilder({
createUrl,
followUp: `Then run ${highlighter.info(
"shadcn apply --preset <preset>"
)} with the preset code or preset URL from ui.shadcn.com.`,
prompt: !options.yes,
})
process.exit(0)
}
validatePreset(preset)
const only = explicitOnly ?? resolveApplyOnly(getPresetUrlOnly(preset))
const shouldReinstallComponents = !only
const reinstallComponents = shouldReinstallComponents
? await getProjectComponents(options.cwd)
: []
if (!options.yes) {
logger.break()
if (!only) {
logger.warn(
highlighter.warn(
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
)
)
} else {
logger.warn(
highlighter.warn(
`Applying the selected preset parts will update your project configuration and styles.`
)
)
}
logger.warn(
`Commit or stash your changes before continuing so you can easily go back.`
)
if (shouldReinstallComponents) {
logger.break()
logger.log(" The following components will be re-installed:")
if (reinstallComponents.length) {
for (let i = 0; i < reinstallComponents.length; i += 8) {
logger.log(
` - ${reinstallComponents.slice(i, i + 8).join(", ")}`
)
}
} else {
logger.log(" - No installed UI components were detected.")
}
}
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Would you like to continue?",
initial: false,
})
if (!proceed) {
logger.break()
process.exit(1)
}
}
await loadEnvFiles(options.cwd)
const currentBase = getBase(existingConfig.style)
const initUrl = resolveApplyInitUrl(preset, currentBase, {
template,
rtl,
only: only?.join(","),
})
const config = await withFileBackup(
path.resolve(options.cwd, "components.json"),
async () => {
const {
registryBaseConfig,
installStyleIndex,
url: cleanUrl,
} = await resolveRegistryBaseConfig(initUrl, options.cwd, {
registries: existingConfig.registries as
| z.infer<typeof registryConfigSchema>
| undefined,
})
const applyRegistryBaseConfig = resolveApplyRegistryBaseConfig({
registryBaseConfig,
existingConfig,
only,
})
return await runInit({
cwd: options.cwd,
yes: true,
force: false,
reinstall: shouldReinstallComponents,
defaults: false,
silent: options.silent,
isNewProject: false,
cssVariables: true,
installStyleIndex,
registryBaseConfig: applyRegistryBaseConfig,
existingConfig,
components: [cleanUrl, ...reinstallComponents],
})
}
)
await syncApplyWorkspaceConfigs(config, { only })
logger.break()
logger.log("Preset applied successfully.")
logger.break()
} catch (error) {
if (error instanceof ApplyOnlyError) {
for (const line of error.message.split("\n")) {
logger.error(line)
}
logger.break()
process.exit(1)
}
if (error instanceof FileBackupError) {
logger.error(
`Could not back up ${highlighter.info("components.json")}. Aborting.`
)
logger.break()
process.exit(1)
}
if (error instanceof ApplyWorkspaceSyncError) {
logger.error(error.message)
logger.break()
process.exit(1)
}
logger.break()
handleError(error)
} finally {
clearRegistryContext()
}
})
function resolveApplyPreset(options: z.infer<typeof applyOptionsSchema>) {
const positionalPreset = options.positionalPreset?.trim()
const flagPreset = options.preset?.trim()
if (positionalPreset && flagPreset && positionalPreset !== flagPreset) {
logger.error(
`Received two different preset values. Use either the positional preset or ${highlighter.info(
"--preset"
)}, or pass the same value to both.`
)
logger.break()
process.exit(1)
}
return flagPreset ?? positionalPreset
}
export function getPresetUrlOnly(preset: string) {
if (!isUrl(preset)) {
return undefined
}
const url = new URL(preset)
if (url.pathname !== "/init") {
return undefined
}
return url.searchParams.get("only") ?? undefined
}
export function resolveApplyOnly(
value: z.infer<typeof applyOptionsSchema>["only"]
) {
if (value === undefined || value === false) {
return undefined
}
if (value === true) {
throw new ApplyOnlyError(
[
"Missing value for --only.",
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
}
return parseApplyOnlyParts(value)
}
export function parseApplyOnlyParts(value: string) {
const aliases: Record<string, ApplyOnlyValue> = {
theme: "theme",
font: "font",
fonts: "font",
}
const parts = value
.split(",")
.map((part) => part.trim().toLowerCase())
.filter(Boolean)
const invalid = parts.filter((part) => !aliases[part])
if (!parts.length || invalid.length) {
throw new ApplyOnlyError(
[
`Invalid value for --only: ${value}.`,
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
}
return Array.from(new Set(parts.map((part) => aliases[part])))
}
export function validateApplyOnlyPreset(options: {
preset?: string
only?: ApplyOnlyValue[]
}) {
if (!options.only || options.preset) {
return
}
throw new ApplyOnlyError(
[
"Missing preset for --only.",
"Use: shadcn apply <preset> --only theme,font.",
].join("\n")
)
}
function resolveApplyRegistryBaseConfig(options: {
registryBaseConfig: Record<string, unknown> | undefined
existingConfig: Record<string, unknown>
only: ApplyOnlyValue[] | undefined
}) {
if (!options.only || options.only.includes("theme")) {
return options.registryBaseConfig
}
const existingTailwind =
typeof options.existingConfig.tailwind === "object" &&
options.existingConfig.tailwind !== null
? options.existingConfig.tailwind
: {}
const registryTailwind =
typeof options.registryBaseConfig?.tailwind === "object" &&
options.registryBaseConfig.tailwind !== null
? options.registryBaseConfig.tailwind
: {}
const config: Record<string, unknown> = {
...options.registryBaseConfig,
tailwind: {
...existingTailwind,
...registryTailwind,
},
}
if (options.existingConfig.menuColor) {
config.menuColor = options.existingConfig.menuColor
}
if (options.existingConfig.menuAccent) {
config.menuAccent = options.existingConfig.menuAccent
}
return config
}
function validatePreset(preset: string) {
if (isUrl(preset) || isPresetCode(preset)) {
return
}
const knownPresetNames = Object.keys(DEFAULT_PRESETS)
if (!knownPresetNames.includes(preset)) {
logger.error(
`Invalid preset: ${highlighter.info(
preset
)}.\nUse one of the available presets: ${knownPresetNames.join(", ")} \nor build your own at ${highlighter.info(`${SHADCN_URL}/create`)}`
)
logger.break()
process.exit(1)
}
}
async function resolveApplyTemplate(cwd: string) {
const projectInfo = await getProjectInfo(cwd)
return getTemplateForFramework(projectInfo?.framework.name)
}
async function syncApplyWorkspaceConfigs(
config: Config,
options?: {
only?: string[]
}
) {
if (options?.only && !options.only.includes("theme")) {
return
}
const linkedConfigs = await getApplyWorkspaceConfigs(config)
if (!linkedConfigs.length) {
return
}
const patch = {
style: config.style,
tailwind: {
baseColor: config.tailwind.baseColor,
cssVariables: config.tailwind.cssVariables,
},
...(config.iconLibrary ? { iconLibrary: config.iconLibrary } : {}),
...(config.rtl !== undefined ? { rtl: config.rtl } : {}),
...(config.menuColor ? { menuColor: config.menuColor } : {}),
...(config.menuAccent ? { menuAccent: config.menuAccent } : {}),
}
const workspaceConfigs = []
for (const linkedConfig of linkedConfigs) {
const configPath = path.resolve(
linkedConfig.resolvedPaths.cwd,
"components.json"
)
if (!(await fs.pathExists(configPath))) {
continue
}
workspaceConfigs.push({
configPath,
existingConfig: await fs.readJson(configPath),
})
}
try {
for (const workspaceConfig of workspaceConfigs) {
const backupPath = createFileBackup(workspaceConfig.configPath)
if (!backupPath) {
throw new FileBackupError(workspaceConfig.configPath)
}
}
for (const workspaceConfig of workspaceConfigs) {
await fs.writeJson(
workspaceConfig.configPath,
{
...workspaceConfig.existingConfig,
...patch,
tailwind: {
...workspaceConfig.existingConfig.tailwind,
...patch.tailwind,
},
},
{ spaces: 2 }
)
}
for (const workspaceConfig of workspaceConfigs) {
deleteFileBackup(workspaceConfig.configPath)
}
} catch (error) {
for (const workspaceConfig of [...workspaceConfigs].reverse()) {
restoreFileBackup(workspaceConfig.configPath)
}
throw new ApplyWorkspaceSyncError(
`Failed to sync linked workspace configs.${error instanceof Error ? ` ${error.message}` : ""}`
)
}
}
async function getApplyWorkspaceConfigs(config: Config) {
const workspaceConfig = await getWorkspaceConfig(config)
if (!workspaceConfig) {
return []
}
const linkedConfigs = new Map<string, Config>()
for (const linkedConfig of Object.values(workspaceConfig)) {
if (linkedConfig.resolvedPaths.cwd === config.resolvedPaths.cwd) {
continue
}
linkedConfigs.set(linkedConfig.resolvedPaths.cwd, linkedConfig)
}
return Array.from(linkedConfigs.values()).sort((a, b) =>
a.resolvedPaths.cwd.localeCompare(b.resolvedPaths.cwd)
)
}
export function resolveApplyInitUrl(
preset: string,
currentBase: PresetBase,
options: { template?: string; rtl?: boolean; only?: string } = {}
) {
if (isUrl(preset)) {
const url = new URL(preset)
if (url.pathname === "/init" && preset.startsWith(SHADCN_URL)) {
url.searchParams.set("track", "1")
}
url.searchParams.set("base", currentBase)
url.searchParams.set("rtl", String(options.rtl ?? false))
if (options.only) {
url.searchParams.set("only", options.only)
}
return url.toString()
}
if (isPresetCode(preset)) {
const decoded = decodePreset(preset)
if (!decoded) {
logger.error(`Invalid preset code: ${highlighter.info(preset)}`)
logger.break()
process.exit(1)
}
return resolveInitUrl(
{
...decoded,
base: currentBase,
rtl: options.rtl ?? false,
},
{ preset, template: options.template, only: options.only }
)
}
const resolvedPreset = DEFAULT_PRESETS[preset as keyof typeof DEFAULT_PRESETS]
return resolveInitUrl(
{
...resolvedPreset,
base: currentBase,
rtl: options.rtl ?? resolvedPreset.rtl,
},
{ template: options.template, only: options.only }
)
}
function quoteShellArg(value: string) {
return /[^A-Za-z0-9_./:-]/.test(value) ? JSON.stringify(value) : value
}
function getInitCommand(preset?: string) {
if (!preset) {
return "shadcn init"
}
return `shadcn init --preset ${quoteShellArg(preset)}`
}

View File

@@ -0,0 +1,142 @@
import * as fs from "fs/promises"
import { tmpdir } from "os"
import * as path from "path"
import { describe, expect, it, vi } from "vitest"
import { build } from "./build"
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn((error) => {
throw error
}),
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: () => ({
start: vi.fn(),
succeed: vi.fn(),
}),
}))
describe("build command", () => {
it("writes flattened registries for source registries that use include", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
include: ["components/ui/registry.json"],
}),
"components/ui/registry.json": JSON.stringify({
items: [
{
name: "button",
type: "registry:ui",
files: [
{
path: "button.tsx",
type: "registry:ui",
},
],
},
],
}),
"components/ui/button.tsx": "export function Button() {}",
})
await build.parseAsync(
["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"],
{ from: "node" }
)
const outputDir = path.join(cwd, "public/r")
const registry = JSON.parse(
await fs.readFile(path.join(outputDir, "registry.json"), "utf-8")
)
const button = JSON.parse(
await fs.readFile(path.join(outputDir, "button.json"), "utf-8")
)
expect(registry).toMatchObject({
name: "example",
homepage: "https://example.com",
items: [
{
name: "button",
files: [
{
path: "components/ui/button.tsx",
},
],
},
],
})
expect(registry).not.toHaveProperty("include")
expect(registry.items[0].files[0]).not.toHaveProperty("content")
expect(button).toMatchObject({
$schema: "https://ui.shadcn.com/schema/registry-item.json",
name: "button",
files: [
{
path: "components/ui/button.tsx",
content: "export function Button() {}",
},
],
})
})
it("creates nested output directories for item names with path segments", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [
{
name: "extension/foo",
type: "registry:item",
files: [
{
path: "registry/extensions/foo.tsx",
type: "registry:file",
target: "extensions/foo.tsx",
},
],
},
],
}),
"registry/extensions/foo.tsx": "export function Foo() {}",
})
await build.parseAsync(
["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"],
{ from: "node" }
)
const item = JSON.parse(
await fs.readFile(path.join(cwd, "public/r/extension/foo.json"), "utf-8")
)
expect(item).toMatchObject({
name: "extension/foo",
files: [
{
path: "registry/extensions/foo.tsx",
content: "export function Foo() {}",
},
],
})
})
})
async function createFixture(files: Record<string, string>) {
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-build-"))
await Promise.all(
Object.entries(files).map(async ([filePath, content]) => {
const targetPath = path.join(cwd, filePath)
await fs.mkdir(path.dirname(targetPath), { recursive: true })
await fs.writeFile(targetPath, content)
})
)
return cwd
}

View File

@@ -0,0 +1,103 @@
import * as fs from "fs/promises"
import * as path from "path"
import { preFlightBuild } from "@/src/preflights/preflight-build"
import {
createRegistryCatalog,
createRegistryItem,
readRegistryWithIncludes,
} from "@/src/registry/loader"
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { Command } from "commander"
import { z } from "zod"
export const buildOptionsSchema = z.object({
cwd: z.string(),
registryFile: z.string(),
outputDir: z.string(),
})
export const build = new Command()
.name("build")
.description("build components for a shadcn registry")
.argument("[registry]", "path to registry.json file", "./registry.json")
.option(
"-o, --output <path>",
"destination directory for json files",
"./public/r"
)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async (registryFile: string, opts) => {
try {
const options = buildOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
registryFile,
outputDir: opts.output,
})
const { resolvePaths } = await preFlightBuild(options)
const registryResult = await readRegistryWithIncludes(
resolvePaths.registryFile,
{
cwd: resolvePaths.cwd,
}
)
const resolvedRegistry = registryResult.registry
const registryRootDir = registryResult.usesInclude
? path.dirname(resolvePaths.registryFile)
: resolvePaths.cwd
const registryCatalog = createRegistryCatalog(
registryResult,
registryRootDir,
resolvePaths.cwd
)
const buildSpinner = spinner("Building registry...")
for (const registryItem of resolvedRegistry.items) {
buildSpinner.start(`Building ${registryItem.name}...`)
const registryItemForBuild = await createRegistryItem(
registryItem,
registryResult,
registryRootDir,
resolvePaths.cwd
)
// Write the registry item to the output directory.
// Item names can contain path segments (e.g. "extension/foo"), so
// ensure the nested output directory exists before writing.
const outputPath = path.resolve(
resolvePaths.outputDir,
`${registryItemForBuild.name}.json`
)
await fs.mkdir(path.dirname(outputPath), { recursive: true })
await fs.writeFile(
outputPath,
JSON.stringify(registryItemForBuild, null, 2)
)
}
if (registryResult.usesInclude) {
await fs.writeFile(
path.resolve(resolvePaths.outputDir, "registry.json"),
JSON.stringify(registryCatalog, null, 2)
)
} else {
// Copy registry.json to the output directory.
await fs.copyFile(
resolvePaths.registryFile,
path.resolve(resolvePaths.outputDir, "registry.json")
)
}
buildSpinner.succeed("Building registry.")
} catch (error) {
logger.break()
handleError(error)
}
})

View File

@@ -0,0 +1,255 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import {
fetchTree,
getItemTargetPath,
getRegistryBaseColor,
getShadcnRegistryIndex,
} from "@/src/registry/api"
import { registryIndexSchema } from "@/src/schema"
import { getSupportedFontMarkers } from "@/src/utils/font-markers"
import { Config, getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { transform } from "@/src/utils/transformers"
import { transformCleanup } from "@/src/utils/transformers/transform-cleanup"
import { transformCssVars } from "@/src/utils/transformers/transform-css-vars"
import { transformFont } from "@/src/utils/transformers/transform-font"
import { transformIcons } from "@/src/utils/transformers/transform-icons"
import { transformImport } from "@/src/utils/transformers/transform-import"
import { transformMenu } from "@/src/utils/transformers/transform-menu"
import { transformRsc } from "@/src/utils/transformers/transform-rsc"
import { transformRtl } from "@/src/utils/transformers/transform-rtl"
import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix"
import { Command } from "commander"
import { diffLines, type Change } from "diff"
import { z } from "zod"
const updateOptionsSchema = z.object({
component: z.string().optional(),
yes: z.boolean(),
cwd: z.string(),
path: z.string().optional(),
})
export const diff = new Command()
.name("diff")
.description("[DEPRECATED] Use `add [component] --diff` instead.")
.argument("[component]", "the component name")
.option("-y, --yes", "skip confirmation prompt.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async (name, opts) => {
try {
const options = updateOptionsSchema.parse({
component: name,
...opts,
})
const cwd = path.resolve(options.cwd)
if (!existsSync(cwd)) {
logger.error(`The path ${cwd} does not exist. Please try again.`)
process.exit(1)
}
const config = await getConfig(cwd)
if (!config) {
// Check if we're in a monorepo root.
if (await isMonorepoRoot(cwd)) {
const targets = await getMonorepoTargets(cwd)
if (targets.length > 0) {
formatMonorepoMessage("diff [component]", targets)
process.exit(1)
}
}
logger.warn(
`Configuration is missing. Please run ${highlighter.success(
`init`
)} to create a components.json file.`
)
process.exit(1)
}
const registryIndex = await getShadcnRegistryIndex()
if (!registryIndex) {
handleError(new Error("Failed to fetch registry index."))
process.exit(1)
}
if (!options.component) {
const targetDir = config.resolvedPaths.components
// Find all components that exist in the project.
const projectComponents = registryIndex.filter((item) => {
for (const file of item.files ?? []) {
const filePath = path.resolve(
targetDir,
typeof file === "string" ? file : file.path
)
if (existsSync(filePath)) {
return true
}
}
return false
})
// Check for updates.
const componentsWithUpdates = []
for (const component of projectComponents) {
const changes = await diffComponent(component, config)
if (changes.length) {
componentsWithUpdates.push({
name: component.name,
changes,
})
}
}
if (!componentsWithUpdates.length) {
logger.info("No updates found.")
process.exit(0)
}
logger.info("The following components have updates available:")
for (const component of componentsWithUpdates) {
logger.info(`- ${component.name}`)
for (const change of component.changes) {
logger.info(` - ${change.filePath}`)
}
}
logger.break()
logger.info(
`Run ${highlighter.success(`diff <component>`)} to see the changes.`
)
process.exit(0)
}
// Show diff for a single component.
const component = registryIndex.find(
(item) => item.name === options.component
)
if (!component) {
logger.error(
`The component ${highlighter.success(
options.component
)} does not exist.`
)
process.exit(1)
}
const changes = await diffComponent(component, config)
if (!changes.length) {
logger.info(`No updates found for ${options.component}.`)
process.exit(0)
}
for (const change of changes) {
logger.info(`- ${change.filePath}`)
await printDiff(change.patch)
logger.info("")
}
} catch (error) {
handleError(error)
}
})
async function diffComponent(
component: z.infer<typeof registryIndexSchema>[number],
config: Config
) {
const payload = await fetchTree(config.style, [component])
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
const supportedFontMarkers = getSupportedFontMarkers(payload)
if (!payload) {
return []
}
const changes = []
for (const item of payload) {
const targetDir = await getItemTargetPath(config, item)
if (!targetDir) {
continue
}
for (const file of item.files ?? []) {
const filePath = path.resolve(
targetDir,
typeof file === "string" ? file : file.path
)
if (!existsSync(filePath)) {
continue
}
const fileContent = await fs.readFile(filePath, "utf8")
if (typeof file === "string" || !file.content) {
continue
}
const registryContent = await transform(
{
filename: file.path,
raw: file.content,
config,
baseColor,
supportedFontMarkers,
},
[
transformImport,
transformRsc,
transformCssVars,
transformTwPrefixes,
transformIcons,
transformMenu,
transformRtl,
transformFont,
transformCleanup,
]
)
const patch = diffLines(registryContent as string, fileContent)
if (patch.length > 1) {
changes.push({
filePath,
patch,
})
}
}
}
return changes
}
async function printDiff(diff: Change[]) {
diff.forEach((part) => {
if (part) {
if (part.added) {
return process.stdout.write(highlighter.success(part.value))
}
if (part.removed) {
return process.stdout.write(highlighter.error(part.value))
}
return process.stdout.write(part.value)
}
})
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest"
import { docs, resolveDocsBase } from "./docs"
describe("resolveDocsBase", () => {
it("accepts aria explicitly", () => {
expect(resolveDocsBase("aria", undefined)).toBe("aria")
expect(docs.helpInformation()).toContain("base, radix, or aria")
})
it("infers aria from the project style", () => {
expect(resolveDocsBase(undefined, "aria-nova")).toBe("aria")
})
it("rejects unsupported bases", () => {
expect(() => resolveDocsBase("unsupported", undefined)).toThrow(
"Expected one of: radix, base, aria"
)
})
})

View File

@@ -0,0 +1,127 @@
import path from "path"
import {
isPresetBase,
PRESET_BASES,
type PresetBase,
} from "@/src/preset/preset"
import { getShadcnRegistryIndex } from "@/src/registry/api"
import { SHADCN_URL } from "@/src/registry/constants"
import { getBase, getConfig } from "@/src/utils/get-config"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
const SHADCN_BASE_URL = "https://ui.shadcn.com"
export const docs = new Command()
.name("docs")
.description("get docs, api references and usage examples for components")
.argument("<components...>", "component names")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option(
"-b, --base <base>",
"the base to use: base, radix, or aria. defaults to project base."
)
.option("--json", "output as JSON.", false)
.action(async (components, opts) => {
try {
const cwd = path.resolve(opts.cwd)
const config = await getConfig(cwd)
const base = resolveDocsBase(opts.base, config?.style)
const index = await getShadcnRegistryIndex()
if (!index) {
logger.error("Failed to fetch the registry index.")
process.exit(1)
}
const results: {
component: string
base: PresetBase
links: Record<string, string>
}[] = []
for (const component of components) {
const item = index.find((entry) => entry.name === component)
if (!item) {
logger.error(
`Component ${highlighter.info(
component
)} not found in the shadcn registry.`
)
process.exit(1)
}
const links = (
item.meta?.links as Record<string, Record<string, string>>
)?.[base]
if (!links || Object.keys(links).length === 0) {
logger.warn(
`No documentation links available for ${highlighter.info(
component
)}.`
)
continue
}
results.push({
component,
base,
links: normalizeLinks(links),
})
}
if (opts.json) {
console.log(JSON.stringify({ base, results }, null, 2))
return
}
// Compute max key length across all results for consistent alignment.
const maxKeyLength = Math.max(
...results.flatMap((r) => Object.keys(r.links).map((k) => k.length))
)
for (const { component, links } of results) {
logger.log(highlighter.info(component))
for (const [key, value] of Object.entries(links)) {
logger.log(` - ${key.padEnd(maxKeyLength + 2)}${value}`)
}
logger.break()
}
} catch (error) {
handleError(error)
}
})
export function resolveDocsBase(base: unknown, style: string | undefined) {
const resolvedBase = base ?? getBase(style)
if (!isPresetBase(resolvedBase)) {
throw new Error(
`Invalid base: ${String(resolvedBase)}. Expected one of: ${PRESET_BASES.join(
", "
)}.`
)
}
return resolvedBase
}
function normalizeLinks(links: Record<string, string>) {
return Object.fromEntries(
Object.entries(links).map(([key, value]) => [
key,
value.startsWith(SHADCN_BASE_URL)
? `${SHADCN_URL}${value.slice(SHADCN_BASE_URL.length)}`
: value,
])
)
}

View File

@@ -0,0 +1,262 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"
import os from "os"
import path from "path"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { execa } from "execa"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { runEject, SHADCN_TAILWIND_IMPORT } from "./eject"
vi.mock("execa", () => ({
execa: vi.fn(),
}))
vi.mock("@/src/utils/get-package-manager", () => ({
getPackageManager: vi.fn().mockResolvedValue("pnpm"),
}))
vi.mock("@/src/utils/get-monorepo-info", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/src/utils/get-monorepo-info")>()
return {
...actual,
formatMonorepoMessage: vi.fn(),
getMonorepoTargets: vi.fn().mockResolvedValue([]),
isMonorepoRoot: vi.fn().mockResolvedValue(false),
}
})
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
const fixtureConfig = {
$schema: "https://ui.shadcn.com/schema.json",
style: "base-nova",
rsc: true,
tsx: true,
tailwind: {
config: "",
css: "app/globals.css",
baseColor: "neutral",
cssVariables: true,
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
},
}
const fixtureTsconfig = {
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./*"],
},
},
}
const baseCss = `@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@layer base {
body {
@apply bg-background text-foreground;
}
}
`
describe("runEject", () => {
let tempDir: string
beforeEach(async () => {
vi.clearAllMocks()
vi.mocked(getPackageManager).mockResolvedValue("pnpm")
tempDir = await mkdtemp(path.join(os.tmpdir(), "shadcn-eject-"))
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
async function setupProject(
cssContent: string,
packageJson: Record<string, unknown> = {
name: "test-app",
dependencies: {
shadcn: "^4.8.3",
},
}
) {
await writeFile(
path.join(tempDir, "components.json"),
JSON.stringify(fixtureConfig, null, 2)
)
await writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(fixtureTsconfig, null, 2)
)
await mkdir(path.join(tempDir, "app"), { recursive: true })
await writeFile(path.join(tempDir, "app", "globals.css"), cssContent)
await writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(packageJson, null, 2)
)
}
it("inlines shadcn/tailwind.css and removes the dependency", async () => {
await setupProject(baseCss)
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
const output = await readFile(
path.join(tempDir, "app", "globals.css"),
"utf8"
)
const shadcnCss = await readFile(
path.join(process.cwd(), "src/tailwind.css"),
"utf8"
)
expect(output).not.toMatch(SHADCN_TAILWIND_IMPORT)
expect(output).toContain('@import "tailwindcss";')
expect(output).toContain('@import "tw-animate-css";')
expect(output).toContain("/* ejected from shadcn@4.8.3 */")
expect(output).toContain(shadcnCss.trim())
expect(output).toContain("@layer base")
expect(output).toContain("@apply bg-background text-foreground;")
expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], {
cwd: tempDir,
})
})
it("removes shadcn from devDependencies", async () => {
await setupProject(baseCss, {
name: "test-app",
devDependencies: {
shadcn: "^4.8.3",
},
})
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
const output = await readFile(
path.join(tempDir, "app", "globals.css"),
"utf8"
)
expect(output).toContain("/* ejected from shadcn@4.8.3 */")
expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], {
cwd: tempDir,
})
})
it("skips dependency removal when shadcn is not in package.json", async () => {
await setupProject(baseCss, {
name: "test-app",
dependencies: {},
})
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
expect(execa).not.toHaveBeenCalled()
})
it("removes shadcn from package.json for deno", async () => {
vi.mocked(getPackageManager).mockResolvedValue("deno")
await setupProject(baseCss)
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
const packageJson = JSON.parse(
await readFile(path.join(tempDir, "package.json"), "utf8")
)
expect(packageJson.dependencies?.shadcn).toBeUndefined()
expect(execa).not.toHaveBeenCalled()
})
it("exits when shadcn/tailwind.css is not imported", async () => {
await setupProject(`@import "tailwindcss";
@import "tw-animate-css";
`)
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit")
}) as never)
await expect(
runEject({
cwd: tempDir,
yes: true,
silent: true,
})
).rejects.toThrow("process.exit")
exit.mockRestore()
})
it("shows monorepo suggestions when run from a monorepo root", async () => {
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
vi.mocked(getMonorepoTargets).mockResolvedValue([
{ name: "packages/ui", hasConfig: true },
{ name: "apps/web", hasConfig: true },
])
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit")
}) as never)
await expect(
runEject({
cwd: tempDir,
yes: true,
silent: true,
})
).rejects.toThrow("process.exit")
expect(formatMonorepoMessage).toHaveBeenCalledWith("eject", [
{ name: "packages/ui", hasConfig: true },
{ name: "apps/web", hasConfig: true },
])
exit.mockRestore()
})
})

View File

@@ -0,0 +1,248 @@
import { promises as fs } from "fs"
import path from "path"
import { SHADCN_URL } from "@/src/registry/constants"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getPackageInfo } from "@/src/utils/get-package-info"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { Command } from "commander"
import { execa } from "execa"
import fsExtra from "fs-extra"
import prompts from "prompts"
import { z } from "zod"
export const SHADCN_TAILWIND_IMPORT =
/@import\s+["']shadcn\/tailwind\.css["'];?\s*\n?/
export const ejectOptionsSchema = z.object({
cwd: z.string(),
yes: z.boolean(),
silent: z.boolean(),
})
export const eject = new Command()
.name("eject")
.description("inline shadcn/tailwind.css and remove the shadcn dependency")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-y, --yes", "skip confirmation prompt.", false)
.option("-s, --silent", "mute output.", false)
.action(async (opts) => {
try {
const options = ejectOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
yes: opts.yes,
silent: opts.silent,
})
await runEject(options)
} catch (error) {
handleError(error)
}
})
export async function runEject(options: z.infer<typeof ejectOptionsSchema>) {
if (!fsExtra.existsSync(path.resolve(options.cwd, "components.json"))) {
if (await isMonorepoRoot(options.cwd)) {
const targets = await getMonorepoTargets(options.cwd)
if (targets.length > 0) {
formatMonorepoMessage("eject", targets)
process.exit(1)
}
}
logger.break()
logger.error(
`No ${highlighter.info("components.json")} found. Run ${highlighter.info("shadcn init")} first.`
)
logger.error(
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
)
logger.break()
process.exit(1)
}
const config = await getConfig(options.cwd)
if (!config?.resolvedPaths.tailwindCss) {
logger.break()
logger.error(
"Could not resolve the Tailwind CSS file from components.json."
)
logger.break()
process.exit(1)
}
const cssFilepath = config.resolvedPaths.tailwindCss
const cssFilepathRelative = path.relative(options.cwd, cssFilepath)
let cssContent = await fs.readFile(cssFilepath, "utf8")
if (!SHADCN_TAILWIND_IMPORT.test(cssContent)) {
logger.break()
logger.error(
`Could not find ${highlighter.info('@import "shadcn/tailwind.css"')} in ${highlighter.info(cssFilepathRelative)}.`
)
logger.error("Nothing to eject.")
logger.break()
process.exit(1)
}
const packageInfo = getPackageInfo(options.cwd, false)
const shadcnVersion = getShadcnVersion(packageInfo)
const shadcnCssPath = resolveShadcnTailwindCss(options.cwd)
const shadcnCssContent = await fs.readFile(shadcnCssPath, "utf8")
if (!options.silent) {
logger.break()
logger.warn(
"This action is not reversible. Future shadcn CLI updates to tailwind.css will not apply automatically."
)
logger.break()
}
if (!options.yes) {
logger.log("This will:")
logger.log(
` - Inline ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}`
)
logger.log(` - Remove the ${highlighter.info("shadcn")} dependency`)
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Proceed?",
initial: false,
})
if (!proceed) {
process.exit(0)
}
}
const ejectSpinner = spinner(
`Inlining ${highlighter.info("shadcn/tailwind.css")}.`,
{
silent: options.silent,
}
)?.start()
cssContent = cssContent.replace(
SHADCN_TAILWIND_IMPORT,
() =>
`/* ejected from shadcn@${shadcnVersion} */\n${shadcnCssContent.trim()}\n\n`
)
await fs.writeFile(cssFilepath, cssContent, "utf8")
ejectSpinner?.succeed()
if (hasShadcnDependency(packageInfo)) {
const removeSpinner = spinner(`Removing ${highlighter.info("shadcn")}.`, {
silent: options.silent,
})?.start()
await removeShadcnDependency(options.cwd)
removeSpinner?.succeed()
} else if (!options.silent) {
logger.warn(
`The ${highlighter.info("shadcn")} package was not found in package.json. Skipped removal.`
)
}
logger.break()
logger.log(
`Ejected ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}.`
)
logger.break()
}
function getShadcnVersion(packageInfo: ReturnType<typeof getPackageInfo>) {
if (!packageInfo) {
return "unknown"
}
return (
packageInfo.dependencies?.shadcn ??
packageInfo.devDependencies?.shadcn ??
"unknown"
)
.replace(/^[\^~]/, "")
.trim()
}
function hasShadcnDependency(packageInfo: ReturnType<typeof getPackageInfo>) {
if (!packageInfo) {
return false
}
return Boolean(
packageInfo.dependencies?.shadcn || packageInfo.devDependencies?.shadcn
)
}
function resolveShadcnTailwindCss(cwd: string) {
const projectCss = path.join(cwd, "node_modules/shadcn/dist/tailwind.css")
if (fsExtra.existsSync(projectCss)) {
return projectCss
}
const cliRoot = process.argv[1]
? path.dirname(path.resolve(process.argv[1]))
: cwd
for (const candidate of [
path.join(cliRoot, "tailwind.css"),
path.join(cliRoot, "dist", "tailwind.css"),
path.join(cliRoot, "src", "tailwind.css"),
path.join(process.cwd(), "src/tailwind.css"),
path.join(process.cwd(), "dist/tailwind.css"),
]) {
if (fsExtra.existsSync(candidate)) {
return candidate
}
}
throw new Error("Could not resolve shadcn/tailwind.css.")
}
async function removeShadcnDependency(cwd: string) {
const packageManager = await getPackageManager(cwd)
switch (packageManager) {
case "npm":
await execa("npm", ["uninstall", "shadcn"], { cwd })
break
case "pnpm":
await execa("pnpm", ["remove", "shadcn"], { cwd })
break
case "yarn":
await execa("yarn", ["remove", "shadcn"], { cwd })
break
case "bun":
await execa("bun", ["remove", "shadcn"], { cwd })
break
case "deno": {
const packageJsonPath = path.join(cwd, "package.json")
const packageJson = await fsExtra.readJson(packageJsonPath)
for (const field of ["dependencies", "devDependencies"] as const) {
if (packageJson[field]?.shadcn) {
delete packageJson[field].shadcn
}
}
await fsExtra.writeJson(packageJsonPath, packageJson, { spaces: 2 })
break
}
}
}

View File

@@ -0,0 +1,299 @@
import { existsSync } from "fs"
import path from "path"
import { resolveProjectPreset } from "@/src/preset/resolve"
import { SHADCN_URL } from "@/src/registry/constants"
import { getBase, getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import {
getProjectComponents,
getProjectInfo,
type ProjectInfo,
} from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
const CODE_BASE = `${SHADCN_URL}/code/apps/v4/registry/bases`
export const info = new Command()
.name("info")
.description("get information about your project")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("--json", "output as JSON.", false)
.action(async (opts) => {
try {
const cwd = path.resolve(opts.cwd)
// Check if we're in a monorepo root.
if (
!existsSync(path.resolve(cwd, "components.json")) &&
(await isMonorepoRoot(cwd))
) {
const targets = await getMonorepoTargets(cwd)
if (targets.length > 0) {
if (opts.json) {
console.log(
JSON.stringify(
{
error: "monorepo_root",
message:
"You are running info from a monorepo root. Use the -c flag to specify a workspace.",
targets: targets.map((t) => t.name),
},
null,
2
)
)
} else {
formatMonorepoMessage("info", targets)
}
process.exit(1)
}
}
const projectInfo = await getProjectInfo(cwd)
const config = await getConfig(cwd)
const components = await getProjectComponents(cwd)
const base = getBase(config?.style)
const data = await collectInfo(projectInfo, config, components, base)
if (opts.json) {
console.log(JSON.stringify(data, null, 2))
return
}
printInfo(data)
} catch (error) {
handleError(error)
}
})
function getRegistries(
registries: Record<string, string | { url: string }> | undefined
) {
if (!registries) {
return {}
}
const result: Record<string, string> = {}
for (const [name, value] of Object.entries(registries)) {
result[name] = typeof value === "string" ? value : value.url
}
return result
}
export async function collectInfo(
projectInfo: ProjectInfo | null,
config: Awaited<ReturnType<typeof getConfig>>,
components: string[],
base: string
) {
const preset = config ? await resolveProjectPreset(config, projectInfo) : null
return {
project: projectInfo
? {
framework: projectInfo.framework.label,
frameworkName: projectInfo.framework.name,
frameworkVersion: projectInfo.frameworkVersion ?? null,
srcDirectory: projectInfo.isSrcDir,
rsc: projectInfo.isRSC,
typescript: projectInfo.isTsx,
tailwindVersion: projectInfo.tailwindVersion ?? null,
tailwindConfig: projectInfo.tailwindConfigFile ?? null,
tailwindCss: projectInfo.tailwindCssFile ?? null,
importAlias: projectInfo.aliasPrefix ?? null,
}
: null,
config: config
? {
style: config.style,
base,
rsc: config.rsc,
typescript: config.tsx,
iconLibrary: config.iconLibrary ?? null,
rtl: config.rtl ?? false,
menuColor: config.menuColor ?? null,
menuAccent: config.menuAccent ?? null,
aliases: {
components: config.aliases.components,
utils: config.aliases.utils,
ui: config.aliases.ui ?? null,
lib: config.aliases.lib ?? null,
hooks: config.aliases.hooks ?? null,
},
resolvedPaths: {
cwd: config.resolvedPaths.cwd,
tailwindConfig: config.resolvedPaths.tailwindConfig || null,
tailwindCss: config.resolvedPaths.tailwindCss || null,
utils: config.resolvedPaths.utils,
components: config.resolvedPaths.components,
lib: config.resolvedPaths.lib,
hooks: config.resolvedPaths.hooks,
ui: config.resolvedPaths.ui,
},
registries: getRegistries(config.registries),
}
: null,
preset,
components,
links: {
docs: `${SHADCN_URL}/docs`,
components: `${SHADCN_URL}/docs/components/${base}/[component].md`,
ui: `${CODE_BASE}/${base}/ui/[component].tsx`,
examples: `${CODE_BASE}/${base}/examples/[component]-example.tsx`,
schema: `${SHADCN_URL}/schema.json`,
},
}
}
export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
// Project.
logger.log(highlighter.info("Project"))
if (data.project) {
printEntries({
framework: `${data.project.framework} (${data.project.frameworkName})`,
frameworkVersion: data.project.frameworkVersion ?? "-",
srcDirectory: data.project.srcDirectory ? "Yes" : "No",
rsc: data.project.rsc ? "Yes" : "No",
typescript: data.project.typescript ? "Yes" : "No",
tailwindVersion: data.project.tailwindVersion ?? "-",
tailwindConfig: data.project.tailwindConfig ?? "-",
tailwindCss: data.project.tailwindCss ?? "-",
importAlias: data.project.importAlias ?? "-",
})
} else {
logger.log(" No project info detected.")
}
// Config.
logger.break()
logger.log(highlighter.info("Configuration"))
if (data.config) {
printEntries({
style: data.config.style,
base: data.config.base,
rsc: data.config.rsc ? "Yes" : "No",
typescript: data.config.typescript ? "Yes" : "No",
iconLibrary: data.config.iconLibrary ?? "-",
rtl: data.config.rtl ? "Yes" : "No",
menuColor: data.config.menuColor ?? "-",
menuAccent: data.config.menuAccent ?? "-",
})
logger.break()
printPresetInfo(data.preset)
// Aliases.
logger.break()
logger.log(highlighter.info("Aliases"))
printEntries({
components: data.config.aliases.components,
utils: data.config.aliases.utils,
ui: data.config.aliases.ui ?? "-",
lib: data.config.aliases.lib ?? "-",
hooks: data.config.aliases.hooks ?? "-",
})
// Resolved paths.
logger.break()
logger.log(highlighter.info("Resolved Paths"))
printEntries({
cwd: data.config.resolvedPaths.cwd,
tailwindConfig: data.config.resolvedPaths.tailwindConfig ?? "-",
tailwindCss: data.config.resolvedPaths.tailwindCss ?? "-",
utils: data.config.resolvedPaths.utils,
components: data.config.resolvedPaths.components,
lib: data.config.resolvedPaths.lib,
hooks: data.config.resolvedPaths.hooks,
ui: data.config.resolvedPaths.ui,
})
// Registries.
if (Object.keys(data.config.registries).length > 0) {
logger.break()
logger.log("registries:")
printEntries(data.config.registries)
}
} else {
logger.log(" No components.json found.")
}
// Installed components.
logger.break()
logger.log(highlighter.info("Installed Components"))
if (data.components.length > 0) {
logger.log(` ${data.components.join(", ")}`)
} else {
logger.log(" No components installed.")
}
// Links.
logger.break()
logger.log(highlighter.info("Links"))
printEntries(data.links)
logger.break()
}
export function printPresetInfo(
preset: Awaited<ReturnType<typeof collectInfo>>["preset"],
options: {
fallbackNote?: string
} = {}
) {
logger.log(highlighter.info("Preset"))
if (!preset?.code) {
printEntries({
code: "-",
})
} else {
const fallbacks = preset.fallbacks ?? []
const formatPresetValue = (key: string, value: string | undefined) => {
const suffix = fallbacks.includes(key) ? "*" : ""
return `${value ?? "-"}${suffix}`
}
printEntries({
code: preset.code,
version: preset.code[0],
style: preset.values?.style ?? "-",
baseColor: formatPresetValue("baseColor", preset.values?.baseColor),
theme: formatPresetValue("theme", preset.values?.theme),
chartColor: formatPresetValue("chartColor", preset.values?.chartColor),
iconLibrary: formatPresetValue("iconLibrary", preset.values?.iconLibrary),
font: formatPresetValue("font", preset.values?.font),
fontHeading: formatPresetValue("fontHeading", preset.values?.fontHeading),
radius: formatPresetValue("radius", preset.values?.radius),
menuAccent: formatPresetValue("menuAccent", preset.values?.menuAccent),
menuColor: formatPresetValue("menuColor", preset.values?.menuColor),
url: `${SHADCN_URL}/create?preset=${preset.code}`,
})
if (fallbacks.length > 0) {
logger.log("")
logger.log(
options.fallbackNote ??
" * Uses preset defaults for values not available as options on shadcn/create."
)
}
}
}
function printEntries(entries: Record<string, string>) {
const maxKeyLength = Math.max(
...Object.keys(entries).map((key) => key.length)
)
for (const [key, value] of Object.entries(entries)) {
logger.log(` ${key.padEnd(maxKeyLength + 2)}${value}`)
}
}

View File

@@ -0,0 +1,298 @@
import { mkdir, mkdtemp, rm } from "fs/promises"
import os from "os"
import path from "path"
import { preFlightInit } from "@/src/preflights/preflight-init"
import { templates } from "@/src/templates"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { MISSING_DIR_OR_EMPTY_PROJECT } from "@/src/utils/errors"
import {
getProjectConfig,
getProjectInfo,
getProjectTailwindVersionFromConfig,
} from "@/src/utils/get-project-info"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { applyInitUrlOptions, init, initOptionsSchema, runInit } from "./init"
vi.mock("@/src/preflights/preflight-init", () => ({
preFlightInit: vi.fn(),
}))
vi.mock("@/src/utils/create-project", () => ({
createProject: vi.fn(),
}))
vi.mock("@/src/utils/add-components", () => ({
addComponents: vi.fn(),
}))
vi.mock("@/src/utils/registries", () => ({
ensureRegistriesInConfig: vi.fn(),
}))
vi.mock("@/src/registry/api", () => ({
getRegistryBaseColors: vi.fn().mockResolvedValue([
{
label: "Zinc",
name: "zinc",
},
]),
getRegistryStyles: vi.fn().mockResolvedValue([
{
label: "New York",
name: "new-york",
},
]),
}))
vi.mock("@/src/utils/get-config", () => ({
DEFAULT_COMPONENTS: "@/components",
DEFAULT_TAILWIND_CONFIG: "tailwind.config.js",
DEFAULT_TAILWIND_CSS: "app/globals.css",
DEFAULT_UTILS: "@/lib/utils",
explorer: {
clearCaches: vi.fn(),
},
getConfig: vi.fn(),
getWorkspaceConfig: vi.fn().mockResolvedValue(null),
resolveConfigPaths: vi.fn(
async (cwd: string, config: Record<string, unknown>) => ({
...config,
resolvedPaths: {
cwd,
tailwindConfig: path.resolve(cwd, "tailwind.config.js"),
tailwindCss: path.resolve(cwd, "src/index.css"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
components: path.resolve(cwd, "src/components"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
ui: path.resolve(cwd, "src/components/ui"),
},
})
),
}))
vi.mock("@/src/utils/get-project-info", () => ({
getProjectComponents: vi.fn().mockResolvedValue([]),
getProjectConfig: vi.fn(),
getProjectInfo: vi.fn(),
getProjectTailwindVersionFromConfig: vi.fn(),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
fail: vi.fn(),
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
error: (value: string) => value,
info: (value: string) => value,
success: (value: string) => value,
warn: (value: string) => value,
},
}))
vi.mock("prompts", () => ({
default: vi.fn(),
}))
const projectInfo = {
framework: {
label: "Vite",
links: {},
name: "vite",
},
frameworkVersion: null,
isRSC: false,
isSrcDir: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
aliasPrefix: "@",
}
function createProjectConfig(cwd: string) {
return {
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "src/index.css",
baseColor: "zinc",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
rtl: false,
aliases: {
components: "@/components",
ui: "@/components/ui",
hooks: "@/hooks",
lib: "@/lib",
utils: "@/lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
components: path.resolve(cwd, "src/components"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
ui: path.resolve(cwd, "src/components/ui"),
},
}
}
function createInitOptions(cwd: string) {
return {
cwd,
yes: true,
defaults: true,
force: false,
reinstall: false,
silent: true,
isNewProject: false,
cssVariables: true,
installStyleIndex: true,
template: "vite",
} as Parameters<typeof runInit>[0]
}
describe("runInit", () => {
let cwd: string
let originalPostInit: typeof templates.vite.postInit
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), "shadcn-init-test-"))
originalPostInit = templates.vite.postInit
vi.mocked(getProjectInfo).mockResolvedValue(projectInfo as any)
vi.mocked(getProjectTailwindVersionFromConfig).mockResolvedValue("v4")
vi.mocked(getProjectConfig).mockImplementation(async (projectCwd) =>
createProjectConfig(projectCwd)
)
vi.mocked(ensureRegistriesInConfig).mockImplementation(
async (_components, config) => ({
config,
newRegistries: [],
discoveredRegistries: {},
packageJsonRegistries: {},
})
)
vi.mocked(addComponents).mockResolvedValue(undefined)
})
afterEach(async () => {
templates.vite.postInit = originalPostInit
vi.clearAllMocks()
await rm(cwd, { recursive: true, force: true })
})
it("does not run template postInit for existing projects with an explicit template", async () => {
const postInit = vi.fn()
templates.vite.postInit = postInit
vi.mocked(preFlightInit).mockResolvedValue({
errors: {},
projectInfo: projectInfo as any,
})
await runInit(createInitOptions(cwd))
expect(postInit).not.toHaveBeenCalled()
})
it("runs template postInit after creating a new project", async () => {
const projectPath = path.join(cwd, "vite-app")
await mkdir(projectPath)
const postInit = vi.fn()
templates.vite.postInit = postInit
vi.mocked(preFlightInit).mockResolvedValue({
errors: {
[MISSING_DIR_OR_EMPTY_PROJECT]: true,
},
projectInfo: null,
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "vite-app",
template: "vite",
})
await runInit(createInitOptions(cwd))
expect(postInit).toHaveBeenCalledWith({ projectPath })
})
it("does not run template postInit when isNewProject is true but createProject was skipped", async () => {
const postInit = vi.fn()
templates.vite.postInit = postInit
await runInit({
...createInitOptions(cwd),
skipPreflight: true,
isNewProject: true,
})
expect(createProject).not.toHaveBeenCalled()
expect(postInit).not.toHaveBeenCalled()
})
})
describe("init options", () => {
it("accepts aria as a base", () => {
const result = initOptionsSchema.parse({
...createInitOptions("/tmp/project"),
base: "aria",
})
expect(result.base).toBe("aria")
expect(init.helpInformation()).toContain("(base, radix, aria)")
})
it("parses pointer flags", () => {
const result = initOptionsSchema.parse({
...createInitOptions("/tmp/project"),
pointer: true,
})
expect(result.pointer).toBe(true)
})
it("applies pointer to raw init urls", () => {
const url = applyInitUrlOptions(
new URL("https://ui.shadcn.com/init?preset=a0"),
{ pointer: true }
)
expect(url.searchParams.get("pointer")).toBe("true")
})
it("removes pointer from raw init urls when disabled", () => {
const url = applyInitUrlOptions(
new URL("https://ui.shadcn.com/init?preset=a0&pointer=true"),
{ pointer: false }
)
expect(url.searchParams.has("pointer")).toBe(false)
})
})

Some files were not shown because too many files have changed in this diff Show More