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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
# Recipes that import something the package does not export, or tables with a
# stale default, fail here rather than in a reader's project.
- run: npm run check:docs
# An app that imports only the hooks must not bundle the reveal.
- run: npm run check:bundle
- name: Skill references match the docs
run: node scripts/build-skill.mjs --check
# The playground, the docs site, the Markdown copies and llms.txt, as deployed.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
- run: npm run build
- run: npm run check:package
- run: npm run check:docs
- run: npm run check:bundle
- run: node scripts/build-skill.mjs --check
- name: Publish with provenance
run: npm publish --provenance --access public
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ npm run lint && npm run typecheck
npm run build # dist/
npm run check:package # publint + are-the-types-wrong
npm run check:docs # docs and README against the built package (needs build)
npm run check:bundle # importing only the hooks leaves the reveal out (needs build)
node scripts/build-skill.mjs # regenerate skill references after changing docs
npm run dev # playground on 5188
npm run dev:docs # docs on 5194
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.2.2 - 2026-10-16

### Fixed

- Importing only the hooks no longer bundles the reveal. `useSmoothText`,
`useSmoothStream` and `useSmoothStreamController` from `smooth-stream-text/react`,
and `useSmoothStream` from `smooth-stream-text/vue`, brought the reveal code along,
because the components next to them were created by calls a bundler could not
drop. Minified with esbuild and gzipped, the React hooks go from 8.2 kB to 4.7 kB
and the Vue composable from 8.2 kB to 4.6 kB; the core alone is 4.2 kB. An app
that uses the reveal bundles the same code as before. CI now fails if the hooks
pull the reveal in again.

## 0.2.1 - 2026-10-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Integrating smooth-stream-text

A short guide that ships inside the package, for people and coding assistants working
offline. It covers version 0.2.1. The full documentation, with a page per framework and
offline. It covers version 0.2.2. The full documentation, with a page per framework and
runnable examples, is at https://smooth-stream-text.vercel.app/docs/ (plain Markdown:
https://smooth-stream-text.vercel.app/llms.txt).

Expand Down
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "smooth-stream-text",
"version": "0.2.1",
"version": "0.2.2",
"description": "Adaptive client-side pacing for streamed LLM text. Keep your Markdown renderer; the answer appears at an even rate. Zero-dependency headless core with React, Vue, Svelte and Web Component bindings and an optional fade-in reveal.",
"author": "Maksim Zolotoi",
"license": "MIT",
Expand Down Expand Up @@ -153,9 +153,10 @@
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"lint": "eslint .",
"prepublishOnly": "npm run build && npm test && npm run check:package && npm run check:docs",
"prepublishOnly": "npm run build && npm test && npm run check:package && npm run check:docs && npm run check:bundle",
"check:package": "publint && attw --pack . --exclude-entrypoints styles.css",
"check:docs": "node scripts/check-docs.mjs"
"check:docs": "node scripts/check-docs.mjs",
"check:bundle": "node scripts/check-tree-shaking.mjs"
},
"peerDependencies": {
"react": ">=18",
Expand All @@ -177,6 +178,7 @@
"@types/node": "^20.11.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"esbuild": "^0.27.2",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
Expand Down
64 changes: 64 additions & 0 deletions scripts/check-tree-shaking.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env node
// Checks that an app importing only the pacing hooks does not bundle the reveal.
// A bundler keeps every call at the top of a module unless it is marked pure, so a
// single unmarked `forwardRef` or `defineComponent` in a shared entry is enough to
// pull the reveal into apps that never use it, doubling what they ship.
//
// npm run build && node scripts/check-tree-shaking.mjs
//
// Each case bundles the named imports from the built entry, the way an app's
// bundler would, and looks for a class name only the reveal uses. The controls
// import the reveal itself, so a check that can no longer see it fails too.

import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'

// WORD_CLASS in src/reveal/styles.ts. If it is renamed, the controls fail here.
const REVEAL_MARKER = 'sst-word'

const CASES = [
{ entry: 'smooth-stream-text', names: ['createSmoothStream', 'createSourceSync'], reveal: false },
{
entry: 'smooth-stream-text/react',
names: ['useSmoothText', 'useSmoothStream', 'useSmoothStreamController'],
reveal: false,
},
{ entry: 'smooth-stream-text/vue', names: ['useSmoothStream'], reveal: false },
{ entry: 'smooth-stream-text/svelte', names: ['smoothStream'], reveal: false },
{ entry: 'smooth-stream-text/react', names: ['Reveal'], reveal: true },
{ entry: 'smooth-stream-text/vue', names: ['Reveal'], reveal: true },
{ entry: 'smooth-stream-text/svelte', names: ['reveal'], reveal: true },
]

const problems = []
for (const { entry, names, reveal } of CASES) {
const file = fileURLToPath(import.meta.resolve(entry))
const result = await build({
stdin: {
contents: `export { ${names.join(', ')} } from ${JSON.stringify(file)}`,
resolveDir: dirname(file),
loader: 'js',
},
bundle: true,
write: false,
format: 'esm',
// React, Vue and Svelte belong to the app, not to this package.
packages: 'external',
logLevel: 'silent',
})
const code = result.outputFiles[0].text
const label = `{ ${names.join(', ')} } from '${entry}'`
if (code.includes(REVEAL_MARKER) !== reveal) {
problems.push(reveal ? `${label} no longer contains the reveal` : `${label} bundles the reveal`)
}
console.log(`${code.includes(REVEAL_MARKER) === reveal ? 'ok ' : 'FAIL'} ${label}: ${code.length} bytes`)
}

if (problems.length) {
console.error(
`\nTree shaking is broken:\n- ${problems.join('\n- ')}\n` +
'Mark calls at the top of a module /* @__PURE__ */, or move them out of the shared entry.',
)
process.exit(1)
}
2 changes: 1 addition & 1 deletion skills/smooth-stream-text/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Integrates the smooth-stream-text npm package to pace streamed LLM
license: MIT
metadata:
package: smooth-stream-text
package-version: "0.2.1"
package-version: "0.2.2"
docs: https://smooth-stream-text.vercel.app/docs/
---

Expand Down
2 changes: 1 addition & 1 deletion skills/smooth-stream-text/references/ai-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> Pace answers from the Vercel AI SDK's useChat in the browser with smooth-stream-text. Message parts, tool calls, status, stop, errors, history, and how it relates to smoothStream().
>
> smooth-stream-text 0.2.1. HTML version: https://smooth-stream-text.vercel.app/docs/integrations/ai-sdk
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/integrations/ai-sdk

The [AI SDK](https://ai-sdk.dev) streams an assistant message into `useChat` as a list
of parts. This page shows how to pace that message in the browser while keeping
Expand Down
4 changes: 2 additions & 2 deletions skills/smooth-stream-text/references/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

> Complete API reference for smooth-stream-text 0.2 - entry points, the headless core, pacing and reveal options with defaults, the DOM handle, React hooks and components, Vue, Svelte and the Web Component.
>
> smooth-stream-text 0.2.1. HTML version: https://smooth-stream-text.vercel.app/docs/api
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/api

This page documents version 0.2.1, the current release on npm.
This page documents version 0.2.2, the current release on npm.

## Entry points

Expand Down
2 changes: 1 addition & 1 deletion skills/smooth-stream-text/references/dom.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> Pace and reveal streamed text without a framework using createSmoothText, or with the <smooth-stream-text> custom element in plain HTML, Angular, Solid, Astro or any framework that renders custom elements.
>
> smooth-stream-text 0.2.1. HTML version: https://smooth-stream-text.vercel.app/docs/dom
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/dom

Two ways to use the package without React, Vue or Svelte: the imperative DOM handle,
and a custom element that wraps the same handle.
Expand Down
2 changes: 1 addition & 1 deletion skills/smooth-stream-text/references/latency.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> What pacing costs in time, how the tail of an answer drains, the difference between transport done, pacing done and reveal settled, background tabs, Show all, and why two smoothing layers add up.
>
> smooth-stream-text 0.2.1. HTML version: https://smooth-stream-text.vercel.app/docs/concepts/latency
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/concepts/latency

Pacing trades a small, steady delay for an even flow. This page explains where that
delay comes from, how long the end of an answer takes, and how to control both.
Expand Down
2 changes: 1 addition & 1 deletion skills/smooth-stream-text/references/nextjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> Use smooth-stream-text in the Next.js App Router. Client boundaries, server-rendered conversation history that does not replay, Cache Components, hydration and remounts.
>
> smooth-stream-text 0.2.1. HTML version: https://smooth-stream-text.vercel.app/docs/integrations/nextjs
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/integrations/nextjs

Verified with Next.js 16.4 (App Router, Cache Components on) and React 19.3.

Expand Down
2 changes: 1 addition & 1 deletion skills/smooth-stream-text/references/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> Pace streamed LLM text in React with useSmoothText and keep react-markdown or any other renderer. Hooks, the SmoothText component, the optional reveal, history, Show all and StrictMode.
>
> smooth-stream-text 0.2.1. HTML version: https://smooth-stream-text.vercel.app/docs/react
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/react

```ts
import { useSmoothText, useSmoothStream, useSmoothStreamController, SmoothText, Reveal, RevealProvider } from 'smooth-stream-text/react'
Expand Down
2 changes: 1 addition & 1 deletion skills/smooth-stream-text/references/streamdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> Use smooth-stream-text for pacing and Streamdown for rendering and animation, with exactly one animation layer. When Streamdown alone is enough, reduced motion, and the Tailwind setup Streamdown needs.
>
> smooth-stream-text 0.2.1. HTML version: https://smooth-stream-text.vercel.app/docs/integrations/streamdown
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/integrations/streamdown

[Streamdown](https://streamdown.ai) renders Markdown for streaming and can fade words in
as they mount. Its documentation describes the animation as purely visual: it does not
Expand Down
2 changes: 1 addition & 1 deletion skills/smooth-stream-text/references/svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> Pace streamed LLM text in Svelte 4 and 5 and SvelteKit with the smoothStream store or the smoothText action. Runes, cleanup, Show all, and server-rendered history.
>
> smooth-stream-text 0.2.1. HTML version: https://smooth-stream-text.vercel.app/docs/svelte
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/svelte

```ts
import { smoothStream, smoothText, reveal } from 'smooth-stream-text/svelte'
Expand Down
2 changes: 1 addition & 1 deletion skills/smooth-stream-text/references/vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

> Pace streamed LLM text in Vue 3 and Nuxt with useSmoothStream and keep your own Markdown rendering. Refs and getters, the SmoothText component, KeepAlive, and server-rendered history in Nuxt.
>
> smooth-stream-text 0.2.1. HTML version: https://smooth-stream-text.vercel.app/docs/vue
> smooth-stream-text 0.2.2. HTML version: https://smooth-stream-text.vercel.app/docs/vue

```ts
import { useSmoothStream, SmoothText, Reveal, RevealProvider } from 'smooth-stream-text/vue'
Expand Down
6 changes: 4 additions & 2 deletions src/react/reveal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export interface RevealContextValue {
timeline: WaveTimeline
}

const RevealContext = createContext<RevealContextValue | null>(null)
// Calls at the top of a module are kept by bundlers unless marked pure. Unmarked,
// they would pull the whole reveal into an app that imports only the hooks.
const RevealContext = /* @__PURE__ */ createContext<RevealContextValue | null>(null)

// useLayoutEffect warns during server rendering, where there is no layout to read.
export const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
Expand Down Expand Up @@ -120,7 +122,7 @@ export interface RevealProps extends Omit<HTMLAttributes<HTMLElement>, 'children
* Remounts are silent by design: a fresh handle treats the text it is handed as
* already settled, so a markdown parser recreating a node does not replay it.
*/
export const Reveal = forwardRef<HTMLElement, RevealProps>(function Reveal(
export const Reveal = /* @__PURE__ */ forwardRef<HTMLElement, RevealProps>(function Reveal(
{ children = '', as = 'span', initialText, ...attributes },
ref,
) {
Expand Down
9 changes: 6 additions & 3 deletions src/react/smooth-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,17 @@ export interface SmoothTextProps
onSettled?: () => void
}

const OPTION_KEYS = new Set([...Object.keys(defaultPacing), ...Object.keys(defaultReveal)])
// A lookup rather than a set built at the top of the module, which bundlers could
// not drop from an app that never renders SmoothText.
const isOption = (key: string) =>
Object.prototype.hasOwnProperty.call(defaultPacing, key) || Object.prototype.hasOwnProperty.call(defaultReveal, key)

/** Settings for the engine on one side, attributes for the host element on the other. */
function splitProps(props: Record<string, unknown>) {
const options: Record<string, unknown> = {}
const attributes: Record<string, unknown> = {}
for (const [key, value] of Object.entries(props)) {
if (OPTION_KEYS.has(key)) options[key] = value
if (isOption(key)) options[key] = value
else attributes[key] = value
}
return { options: options as SmoothTextOptions, attributes }
Expand All @@ -52,7 +55,7 @@ function splitProps(props: Record<string, unknown>) {
* when it first arrives is history and appears at once; every setting can change
* at any time; other props - `id`, `aria-*`, `style`, a `ref` - go to the host.
*/
export const SmoothText = forwardRef<HTMLElement, SmoothTextProps>(function SmoothText(
export const SmoothText = /* @__PURE__ */ forwardRef<HTMLElement, SmoothTextProps>(function SmoothText(
{ text, done = false, as = 'span', className, unitClassName, onSettled, ...rest },
ref,
) {
Expand Down
10 changes: 7 additions & 3 deletions src/vue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,16 @@ export function useSmoothStream(
}
}

// The components are marked pure: calls at the top of a module are kept by
// bundlers unless marked, and would pull the reveal into an app that imports
// only `useSmoothStream`.

/**
* Shares one wave across every `Reveal` beneath it, so the stagger stays
* continuous across paragraphs. Renders its default slot and no DOM of its own.
* Its options may change at any time; every `Reveal` below follows them.
*/
export const RevealProvider = defineComponent({
export const RevealProvider = /* @__PURE__ */ defineComponent({
name: 'RevealProvider',
props: {
options: { type: Object as PropType<RevealOptions>, default: undefined },
Expand All @@ -147,7 +151,7 @@ export const RevealProvider = defineComponent({
})

/** Applies the wave to one piece of text inside your own markup. */
export const Reveal = defineComponent({
export const Reveal = /* @__PURE__ */ defineComponent({
name: 'Reveal',
props: {
text: { type: String, default: '' },
Expand Down Expand Up @@ -199,7 +203,7 @@ export const Reveal = defineComponent({
* unmounted before then. A message that is already `done` when it first arrives
* is history and appears at once.
*/
export const SmoothText = defineComponent({
export const SmoothText = /* @__PURE__ */ defineComponent({
name: 'SmoothText',
props: {
text: { type: String, default: '' },
Expand Down
3 changes: 3 additions & 0 deletions tests/react.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,9 @@ describe('SmoothText lifecycle', () => {
expect(host.className).toBe('host')
expect(host.getAttribute('aria-busy')).toBe('true')
expect(host.querySelector('.sst-word.unit')).not.toBeNull()
// Settings go to the engine, never onto the element.
expect(host.hasAttribute('boundary')).toBe(false)
expect(host.hasAttribute('clock')).toBe(false)
})
})

Expand Down
2 changes: 1 addition & 1 deletion website/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Complete API reference for smooth-stream-text 0.2 - entry points, t

# API reference

This page documents version 0.2.1, the current release on npm.
This page documents version 0.2.2, the current release on npm.

## Entry points

Expand Down
4 changes: 3 additions & 1 deletion website/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ peak at a few hundred on a fast stream and are gone once the answer settles.

These benchmarks found a bug: after a pause inside a held construct, the next chunk
appeared all at once. It is fixed in 0.2.1 (see the [changelog](/changelog)), and the
results below include the fix.
results below include the fix. They also showed that importing only the React or Vue
hooks bundled the reveal as well, about twice the size of the core; that is fixed in
0.2.2.

## Results

Expand Down
2 changes: 1 addition & 1 deletion website/comparisons.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ app, you do not need a dependency.

## Where smooth-stream-text fits

`smooth-stream-text` 0.2.1 is a client-side pacing controller that keeps your
`smooth-stream-text` 0.2.2 is a client-side pacing controller that keeps your
renderer. Its rate follows the backlog to hold a target latency (180 ms by default)
instead of a fixed speed; its cuts respect grapheme clusters and open Markdown
constructs; it treats finished text as history; and it has bindings for React, Vue,
Expand Down
Loading