diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 442fbf3..a002141 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6035b75..faeffdb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 0123b95..c1b924d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6613db1..ef1ca4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/INTEGRATION.md b/INTEGRATION.md index cef103c..2f1abdd 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -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). diff --git a/package-lock.json b/package-lock.json index 94714ac..00fc51d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "smooth-stream-text", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smooth-stream-text", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", @@ -16,6 +16,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", diff --git a/package.json b/package.json index e2f300f..bf4eb22 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", diff --git a/scripts/check-tree-shaking.mjs b/scripts/check-tree-shaking.mjs new file mode 100644 index 0000000..18d97fb --- /dev/null +++ b/scripts/check-tree-shaking.mjs @@ -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) +} diff --git a/skills/smooth-stream-text/SKILL.md b/skills/smooth-stream-text/SKILL.md index 99ee655..05d9c7a 100644 --- a/skills/smooth-stream-text/SKILL.md +++ b/skills/smooth-stream-text/SKILL.md @@ -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/ --- diff --git a/skills/smooth-stream-text/references/ai-sdk.md b/skills/smooth-stream-text/references/ai-sdk.md index cda4ff7..b6fe1a9 100644 --- a/skills/smooth-stream-text/references/ai-sdk.md +++ b/skills/smooth-stream-text/references/ai-sdk.md @@ -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 diff --git a/skills/smooth-stream-text/references/api.md b/skills/smooth-stream-text/references/api.md index 48a4710..9089d06 100644 --- a/skills/smooth-stream-text/references/api.md +++ b/skills/smooth-stream-text/references/api.md @@ -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 diff --git a/skills/smooth-stream-text/references/dom.md b/skills/smooth-stream-text/references/dom.md index 322c877..a9a0655 100644 --- a/skills/smooth-stream-text/references/dom.md +++ b/skills/smooth-stream-text/references/dom.md @@ -4,7 +4,7 @@ > Pace and reveal streamed text without a framework using createSmoothText, or with the 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. diff --git a/skills/smooth-stream-text/references/latency.md b/skills/smooth-stream-text/references/latency.md index 25922ea..fee62c0 100644 --- a/skills/smooth-stream-text/references/latency.md +++ b/skills/smooth-stream-text/references/latency.md @@ -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. diff --git a/skills/smooth-stream-text/references/nextjs.md b/skills/smooth-stream-text/references/nextjs.md index 3e80cdf..748dee8 100644 --- a/skills/smooth-stream-text/references/nextjs.md +++ b/skills/smooth-stream-text/references/nextjs.md @@ -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. diff --git a/skills/smooth-stream-text/references/react.md b/skills/smooth-stream-text/references/react.md index d74f022..1bb3602 100644 --- a/skills/smooth-stream-text/references/react.md +++ b/skills/smooth-stream-text/references/react.md @@ -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' diff --git a/skills/smooth-stream-text/references/streamdown.md b/skills/smooth-stream-text/references/streamdown.md index 9562d6f..20650d0 100644 --- a/skills/smooth-stream-text/references/streamdown.md +++ b/skills/smooth-stream-text/references/streamdown.md @@ -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 diff --git a/skills/smooth-stream-text/references/svelte.md b/skills/smooth-stream-text/references/svelte.md index 42f5e53..88f54ac 100644 --- a/skills/smooth-stream-text/references/svelte.md +++ b/skills/smooth-stream-text/references/svelte.md @@ -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' diff --git a/skills/smooth-stream-text/references/vue.md b/skills/smooth-stream-text/references/vue.md index 4864f08..97ffe01 100644 --- a/skills/smooth-stream-text/references/vue.md +++ b/skills/smooth-stream-text/references/vue.md @@ -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' diff --git a/src/react/reveal.tsx b/src/react/reveal.tsx index 5199cff..8c1f25b 100644 --- a/src/react/reveal.tsx +++ b/src/react/reveal.tsx @@ -25,7 +25,9 @@ export interface RevealContextValue { timeline: WaveTimeline } -const RevealContext = createContext(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(null) // useLayoutEffect warns during server rendering, where there is no layout to read. export const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect @@ -120,7 +122,7 @@ export interface RevealProps extends Omit, '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(function Reveal( +export const Reveal = /* @__PURE__ */ forwardRef(function Reveal( { children = '', as = 'span', initialText, ...attributes }, ref, ) { diff --git a/src/react/smooth-text.tsx b/src/react/smooth-text.tsx index b50ebac..19b1e3c 100644 --- a/src/react/smooth-text.tsx +++ b/src/react/smooth-text.tsx @@ -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) { const options: Record = {} const attributes: Record = {} 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 } @@ -52,7 +55,7 @@ function splitProps(props: Record) { * 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(function SmoothText( +export const SmoothText = /* @__PURE__ */ forwardRef(function SmoothText( { text, done = false, as = 'span', className, unitClassName, onSettled, ...rest }, ref, ) { diff --git a/src/vue/index.ts b/src/vue/index.ts index e83cd79..aac9f41 100644 --- a/src/vue/index.ts +++ b/src/vue/index.ts @@ -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, default: undefined }, @@ -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: '' }, @@ -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: '' }, diff --git a/tests/react.test.tsx b/tests/react.test.tsx index c420e40..b9c8247 100644 --- a/tests/react.test.tsx +++ b/tests/react.test.tsx @@ -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) }) }) diff --git a/website/api.md b/website/api.md index 48d0c45..5e1322e 100644 --- a/website/api.md +++ b/website/api.md @@ -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 diff --git a/website/benchmarks.md b/website/benchmarks.md index 6e4b05f..7df19af 100644 --- a/website/benchmarks.md +++ b/website/benchmarks.md @@ -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 diff --git a/website/comparisons.md b/website/comparisons.md index d7924b0..f10bce8 100644 --- a/website/comparisons.md +++ b/website/comparisons.md @@ -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,