From 66237e8e3d44017ccc6a215cc363a38fbda84f96 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Wed, 28 Oct 2026 10:36:57 +0000 Subject: [PATCH 1/2] fix: support exactOptionalPropertyTypes in public types --- .changeset/exact-optional-property-types.md | 6 + packages/remend/src/index.ts | 32 ++--- packages/streamdown/index.tsx | 117 +++++++++--------- packages/streamdown/lib/animate.ts | 16 +-- packages/streamdown/lib/icon-context.tsx | 2 +- packages/streamdown/lib/markdown.ts | 29 +++-- packages/streamdown/lib/plugin-types.ts | 46 +++---- packages/streamdown/lib/shiki-types.ts | 20 +-- packages/streamdown/package.json | 1 + .../streamdown/tsconfig.exact-optional.json | 8 ++ packages/streamdown/tsconfig.json | 2 +- .../exact-optional-property-types.tsx | 49 ++++++++ 12 files changed, 200 insertions(+), 128 deletions(-) create mode 100644 .changeset/exact-optional-property-types.md create mode 100644 packages/streamdown/tsconfig.exact-optional.json create mode 100644 packages/streamdown/type-tests/exact-optional-property-types.tsx diff --git a/.changeset/exact-optional-property-types.md b/.changeset/exact-optional-property-types.md new file mode 100644 index 000000000..16a3b71d3 --- /dev/null +++ b/.changeset/exact-optional-property-types.md @@ -0,0 +1,6 @@ +--- +"streamdown": patch +"remend": patch +--- + +Support `exactOptionalPropertyTypes`: add `| undefined` to optional props in the public types so possibly-`undefined` values can be forwarded to `` and `remend` without type errors diff --git a/packages/remend/src/index.ts b/packages/remend/src/index.ts index 0ecf91377..90c0a9f3b 100644 --- a/packages/remend/src/index.ts +++ b/packages/remend/src/index.ts @@ -43,7 +43,7 @@ export interface RemendHandler { /** Unique identifier for this handler */ name: string; /** Priority (lower runs first). Built-in priorities: 0-100. Default: 100 */ - priority?: number; + priority?: number | undefined; } /** @@ -53,42 +53,42 @@ export interface RemendHandler { */ export interface RemendOptions { /** Complete bold formatting (e.g., `**text` → `**text**`) */ - bold?: boolean; + bold?: boolean | undefined; /** Complete bold-italic formatting (e.g., `***text` → `***text***`) */ - boldItalic?: boolean; + boldItalic?: boolean | undefined; /** Escape > as comparison operators in list items (e.g., `- > 25` → `- \> 25`) */ - comparisonOperators?: boolean; + comparisonOperators?: boolean | undefined; /** Custom handlers to extend remend */ - handlers?: RemendHandler[]; + handlers?: RemendHandler[] | undefined; /** Strip incomplete HTML tags at end of streaming text (e.g., `text void; - onError?: (error: Error) => void; + onCopy?: (() => void) | undefined; + onError?: ((error: Error) => void) | undefined; }; export type ControlsConfig = @@ -211,26 +211,29 @@ export type ControlsConfig = table?: | boolean | { - copy?: boolean; - csvSeparator?: CSVSeparator; - download?: DownloadControlConfig; - fullscreen?: boolean; - }; + copy?: boolean | undefined; + csvSeparator?: CSVSeparator | undefined; + download?: DownloadControlConfig | undefined; + fullscreen?: boolean | undefined; + } + | undefined; code?: | boolean | { - copy?: CopyControlConfig; - download?: DownloadControlConfig; - }; + copy?: CopyControlConfig | undefined; + download?: DownloadControlConfig | undefined; + } + | undefined; mermaid?: | boolean | { - download?: DownloadControlConfig; - copy?: CopyControlConfig; - fullscreen?: boolean; - panZoom?: boolean; - }; - image?: boolean | { download?: boolean }; + download?: DownloadControlConfig | undefined; + copy?: CopyControlConfig | undefined; + fullscreen?: boolean | undefined; + panZoom?: boolean | undefined; + } + | undefined; + image?: boolean | { download?: boolean | undefined } | undefined; }; export interface LinkSafetyModalProps { @@ -242,8 +245,8 @@ export interface LinkSafetyModalProps { export interface LinkSafetyConfig { enabled: boolean; - onLinkCheck?: (url: string) => Promise | boolean; - renderModal?: (props: LinkSafetyModalProps) => React.ReactNode; + onLinkCheck?: ((url: string) => Promise | boolean) | undefined; + renderModal?: ((props: LinkSafetyModalProps) => React.ReactNode) | undefined; } export interface MermaidErrorComponentProps { @@ -253,15 +256,15 @@ export interface MermaidErrorComponentProps { } export interface MermaidOptions { - config?: MermaidConfig; - errorComponent?: React.ComponentType; + config?: MermaidConfig | undefined; + errorComponent?: React.ComponentType | undefined; } export type AllowedTags = Record; export type PortalTarget = HTMLElement | null | (() => HTMLElement | null); export type StreamdownProps = Options & { - mode?: "static" | "streaming"; + mode?: "static" | "streaming" | undefined; /** * Text direction. `"ltr"` / `"rtl"` force a single direction. * `"auto"` detects direction per block: in streaming mode via parsed @@ -271,39 +274,39 @@ export type StreamdownProps = Options & { * first-strong as the tie-breaker; fenced/inline code is excluded from * the evidence and code blocks are always rendered LTR. */ - dir?: "auto" | "ltr" | "rtl"; - BlockComponent?: React.ComponentType; - parseMarkdownIntoBlocksFn?: (markdown: string) => string[]; - parseIncompleteMarkdown?: boolean; + dir?: "auto" | "ltr" | "rtl" | undefined; + BlockComponent?: React.ComponentType | undefined; + parseMarkdownIntoBlocksFn?: ((markdown: string) => string[]) | undefined; + parseIncompleteMarkdown?: boolean | undefined; /** Normalize HTML block indentation to prevent 4+ spaces being treated as code blocks. @default false */ - normalizeHtmlIndentation?: boolean; - className?: string; - shikiTheme?: [ThemeInput, ThemeInput]; - mermaid?: MermaidOptions; + normalizeHtmlIndentation?: boolean | undefined; + className?: string | undefined; + shikiTheme?: [ThemeInput, ThemeInput] | undefined; + mermaid?: MermaidOptions | undefined; /** * Max height for fenced code blocks. Numbers are treated as px. * Set to `0` or `Infinity` to disable. @default 400 */ - codeBlockMaxHeight?: number | string; - controls?: ControlsConfig; - isAnimating?: boolean; + codeBlockMaxHeight?: number | string | undefined; + controls?: ControlsConfig | undefined; + isAnimating?: boolean | undefined; /** * Max height for tables. Numbers are treated as px. * Set to `0` or `Infinity` to disable. @default 300 */ - tableMaxHeight?: number | string; - animated?: boolean | AnimateOptions; - caret?: keyof typeof carets; - plugins?: PluginConfig; - remend?: RemendOptions; - linkSafety?: LinkSafetyConfig; + tableMaxHeight?: number | string | undefined; + animated?: boolean | AnimateOptions | undefined; + caret?: keyof typeof carets | undefined; + plugins?: PluginConfig | undefined; + remend?: RemendOptions | undefined; + linkSafety?: LinkSafetyConfig | undefined; /** * DOM node for Streamdown overlays, or a function returning one. * Defaults to `document.body`. */ - portal?: PortalTarget; + portal?: PortalTarget | undefined; /** Custom tags to allow through sanitization with their permitted attributes */ - allowedTags?: AllowedTags; + allowedTags?: AllowedTags | undefined; /** * Fallback component for HTML tags or `allowedTags` entries that have no * matching key in the `components` map. Built-in and explicit `components` @@ -328,7 +331,9 @@ export type StreamdownProps = Options & { * * ``` */ - fallbackComponent?: React.ComponentType & ExtraProps>; + fallbackComponent?: + | React.ComponentType & ExtraProps> + | undefined; /** * Tags whose children should be treated as plain text (no markdown parsing). * Useful for mention/entity tags in AI UIs where child content is a data @@ -344,7 +349,7 @@ export type StreamdownProps = Options & { * * ``` */ - literalTagContent?: string[]; + literalTagContent?: string[] | undefined; /** * Disable GFM / CommonMark autolinking for specific URL protocols (e.g. bare * email addresses become `mailto:` autolinks). Accepts protocol names with @@ -360,15 +365,15 @@ export type StreamdownProps = Options & { * * ``` */ - disableAutolinkProtocols?: string[]; + disableAutolinkProtocols?: string[] | undefined; /** Override UI strings for i18n / custom labels */ - translations?: Partial; + translations?: Partial | undefined; /** Custom icons to override the default icons used in controls */ - icons?: Partial; + icons?: Partial | undefined; /** Tailwind CSS prefix to prepend to all utility classes (e.g. `"tw"` produces `tw:flex` instead of `flex`). Enables Tailwind v4's `prefix()` support. Note: user-supplied `className` values are also prefixed. */ - prefix?: string; + prefix?: string | undefined; /** Show line numbers in code blocks. @default true */ - lineNumbers?: boolean; + lineNumbers?: boolean | undefined; /** * Code fence languages rendered verbatim as plain monospace text, without * syntax highlighting, line numbers or controls. Useful for ASCII / Unicode @@ -376,11 +381,11 @@ export type StreamdownProps = Options & { * Custom renderers and the Mermaid plugin take precedence for matching languages. * @default [] */ - codeBlockRawLanguages?: string[]; + codeBlockRawLanguages?: string[] | undefined; /** Called when isAnimating transitions from false to true. Suppressed in mode="static". */ - onAnimationStart?: () => void; + onAnimationStart?: (() => void) | undefined; /** Called when isAnimating transitions from true to false. Suppressed in mode="static". */ - onAnimationEnd?: () => void; + onAnimationEnd?: (() => void) | undefined; /** `data-*` attributes, like `aria-*` attributes, are set on the root element. */ [dataAttribute: `data-${string}`]: | string @@ -485,15 +490,15 @@ export interface StreamdownContextType { /** Max height for fenced code blocks. @default 400 */ codeBlockMaxHeight: number | string; /** Code fence languages rendered verbatim without highlighting. */ - codeBlockRawLanguages?: string[]; + codeBlockRawLanguages?: string[] | undefined; controls: ControlsConfig; isAnimating: boolean; /** Show line numbers in code blocks. @default true */ lineNumbers: boolean; - linkSafety?: LinkSafetyConfig; - mermaid?: MermaidOptions; + linkSafety?: LinkSafetyConfig | undefined; + mermaid?: MermaidOptions | undefined; mode: "static" | "streaming"; - portal?: PortalTarget; + portal?: PortalTarget | undefined; shikiTheme: [ThemeInput, ThemeInput]; /** Max height for tables. @default 300 */ tableMaxHeight: number | string; @@ -539,9 +544,9 @@ export type BlockProps = Options & { /** Whether this block is incomplete (still being streamed) */ isIncomplete: boolean; /** Resolved text direction for this block */ - dir?: "ltr" | "rtl"; + dir?: "ltr" | "rtl" | undefined; /** Animate plugin instance for tracking previous content length */ - animatePlugin?: AnimatePlugin | null; + animatePlugin?: AnimatePlugin | null | undefined; }; export const Block = memo( diff --git a/packages/streamdown/lib/animate.ts b/packages/streamdown/lib/animate.ts index 642a25666..bcf476124 100644 --- a/packages/streamdown/lib/animate.ts +++ b/packages/streamdown/lib/animate.ts @@ -52,8 +52,8 @@ export interface AnimateTimeline { } export interface CreateAnimateTimelineOptions { - maxBacklogMs?: number; - now?: () => number; + maxBacklogMs?: number | undefined; + now?: (() => number) | undefined; } const defaultNow = (): number => @@ -146,17 +146,17 @@ export interface AnimatePlugin { } export interface AnimateOptions { - animation?: "fadeIn" | "blurIn" | "slideUp" | (string & {}); - duration?: number; - easing?: string; + animation?: "fadeIn" | "blurIn" | "slideUp" | (string & {}) | undefined; + duration?: number | undefined; + easing?: string | undefined; /** * Soft cap (ms) on how far ahead of wall-clock words may be scheduled. * Larger values favour longer cascades / stricter cross-block serialization; * smaller values keep a fast stream visually caught up. @default 320 */ - maxBacklogMs?: number; - sep?: "word" | "char"; - stagger?: number; + maxBacklogMs?: number | undefined; + sep?: "word" | "char" | undefined; + stagger?: number | undefined; } const WHITESPACE_RE = /\s/; diff --git a/packages/streamdown/lib/icon-context.tsx b/packages/streamdown/lib/icon-context.tsx index 3b2b5a623..b0ee393fa 100644 --- a/packages/streamdown/lib/icon-context.tsx +++ b/packages/streamdown/lib/icon-context.tsx @@ -15,7 +15,7 @@ import { } from "./icons"; export type IconComponent = React.ComponentType< - SVGProps & { size?: number } + SVGProps & { size?: number | undefined } >; export interface IconMap { diff --git a/packages/streamdown/lib/markdown.ts b/packages/streamdown/lib/markdown.ts index f15843ce4..78f38f560 100644 --- a/packages/streamdown/lib/markdown.ts +++ b/packages/streamdown/lib/markdown.ts @@ -31,9 +31,12 @@ export type UrlTransform = ( export type Components = { [Key in keyof JSX.IntrinsicElements]?: | ComponentType - | keyof JSX.IntrinsicElements; + | keyof JSX.IntrinsicElements + | undefined; } & { - inlineCode?: ComponentType; + inlineCode?: + | ComponentType + | undefined; [key: string]: | ComponentType & ExtraProps> | keyof JSX.IntrinsicElements @@ -41,17 +44,17 @@ export type Components = { }; export interface Options { - allowElement?: AllowElement; - allowedElements?: readonly string[]; - children?: string; - components?: Components; - disallowedElements?: readonly string[]; - rehypePlugins?: PluggableList; - remarkPlugins?: PluggableList; - remarkRehypeOptions?: Readonly; - skipHtml?: boolean; - unwrapDisallowed?: boolean; - urlTransform?: UrlTransform; + allowElement?: AllowElement | undefined; + allowedElements?: readonly string[] | undefined; + children?: string | undefined; + components?: Components | undefined; + disallowedElements?: readonly string[] | undefined; + rehypePlugins?: PluggableList | undefined; + remarkPlugins?: PluggableList | undefined; + remarkRehypeOptions?: Readonly | undefined; + skipHtml?: boolean | undefined; + unwrapDisallowed?: boolean | undefined; + urlTransform?: UrlTransform | undefined; } // Stable references for common cases diff --git a/packages/streamdown/lib/plugin-types.ts b/packages/streamdown/lib/plugin-types.ts index 222fb4b69..83b979459 100644 --- a/packages/streamdown/lib/plugin-types.ts +++ b/packages/streamdown/lib/plugin-types.ts @@ -18,21 +18,21 @@ export type ThemeInput = BundledTheme | ThemeRegistrationAny; * A single token in a highlighted line */ export interface HighlightToken { - bgColor?: string; - color?: string; + bgColor?: string | undefined; + color?: string | undefined; content: string; - htmlAttrs?: Record; - htmlStyle?: Record; - offset?: number; + htmlAttrs?: Record | undefined; + htmlStyle?: Record | undefined; + offset?: number | undefined; } /** * Result from code highlighting (compatible with shiki's TokensResult) */ export interface HighlightResult { - bg?: string; - fg?: string; - rootStyle?: string | false; + bg?: string | undefined; + fg?: string | undefined; + rootStyle?: string | false | undefined; tokens: HighlightToken[][]; } @@ -45,7 +45,7 @@ export interface HighlightOptions { * Whether the code block is still streaming. Its result is superseded by * the next update, so plugins can avoid caching it long-term. */ - isIncomplete?: boolean; + isIncomplete?: boolean | undefined; language: BundledLanguage; themes: [ThemeInput, ThemeInput]; } @@ -88,13 +88,13 @@ export interface CodeHighlighterPlugin { * Avoids a hard dependency on the "mermaid" package in the core bundle. */ export type MermaidConfig = { - fontFamily?: string; - securityLevel?: string; - startOnLoad?: boolean; - suppressErrorRendering?: boolean; - theme?: string; - themeCSS?: string; - themeVariables?: Record; + fontFamily?: string | undefined; + securityLevel?: string | undefined; + startOnLoad?: boolean | undefined; + suppressErrorRendering?: boolean | undefined; + theme?: string | undefined; + themeCSS?: string | undefined; + themeVariables?: Record | undefined; // biome-ignore lint/suspicious/noExplicitAny: open pass-through for mermaid options } & Record; @@ -137,7 +137,7 @@ export interface MathPlugin { /** * Get CSS styles for math rendering (injected into head) */ - getStyles?: () => string; + getStyles?: (() => string) | undefined; name: "katex"; /** * Get rehype plugin for rendering math @@ -189,7 +189,7 @@ export interface CustomRendererProps { /** Raw metastring from the code fence (everything after the language identifier). * e.g. ```rust {1} title="foo" → meta = '{1} title="foo"' * Undefined when no metastring is present. */ - meta?: string; + meta?: string | undefined; } export interface CustomRenderer { @@ -201,9 +201,9 @@ export interface CustomRenderer { * Plugin configuration passed to Streamdown */ export interface PluginConfig { - cjk?: CjkPlugin; - code?: CodeHighlighterPlugin; - math?: MathPlugin; - mermaid?: DiagramPlugin; - renderers?: CustomRenderer[]; + cjk?: CjkPlugin | undefined; + code?: CodeHighlighterPlugin | undefined; + math?: MathPlugin | undefined; + mermaid?: DiagramPlugin | undefined; + renderers?: CustomRenderer[] | undefined; } diff --git a/packages/streamdown/lib/shiki-types.ts b/packages/streamdown/lib/shiki-types.ts index 5acd6d669..9e457379e 100644 --- a/packages/streamdown/lib/shiki-types.ts +++ b/packages/streamdown/lib/shiki-types.ts @@ -30,14 +30,14 @@ export type BundledTheme = string; * `@streamdown/code` / Shiki remain assignable without a type dependency. */ export interface ThemeRegistrationAny { - bg?: string; - colors?: Record; - displayName?: string; - fg?: string; - name?: string; - semanticHighlighting?: boolean; - semanticTokenColors?: Record; - settings?: unknown[]; - tokenColors?: unknown[]; - type?: "light" | "dark"; + bg?: string | undefined; + colors?: Record | undefined; + displayName?: string | undefined; + fg?: string | undefined; + name?: string | undefined; + semanticHighlighting?: boolean | undefined; + semanticTokenColors?: Record | undefined; + settings?: unknown[] | undefined; + tokenColors?: unknown[] | undefined; + type?: "light" | "dark" | undefined; } diff --git a/packages/streamdown/package.json b/packages/streamdown/package.json index f26b30432..1dd470dd0 100644 --- a/packages/streamdown/package.json +++ b/packages/streamdown/package.json @@ -30,6 +30,7 @@ "bench": "vitest bench --run > results.txt", "bench:ui": "vitest bench --ui --run", "build": "tsup", + "check-types:exact-optional": "pnpm build && tsc -p tsconfig.exact-optional.json", "postbuild": "node scripts/postbuild.js", "size": "node scripts/bundle-size.js", "test": "vitest run", diff --git a/packages/streamdown/tsconfig.exact-optional.json b/packages/streamdown/tsconfig.exact-optional.json new file mode 100644 index 000000000..247f2631d --- /dev/null +++ b/packages/streamdown/tsconfig.exact-optional.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "exactOptionalPropertyTypes": true + }, + "include": ["type-tests/exact-optional-property-types.tsx"], + "exclude": ["node_modules", "build", "dist"] +} diff --git a/packages/streamdown/tsconfig.json b/packages/streamdown/tsconfig.json index 17da37c98..fa5817035 100644 --- a/packages/streamdown/tsconfig.json +++ b/packages/streamdown/tsconfig.json @@ -16,5 +16,5 @@ "noEmit": true }, "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "build", "dist"] + "exclude": ["node_modules", "build", "dist", "type-tests"] } diff --git a/packages/streamdown/type-tests/exact-optional-property-types.tsx b/packages/streamdown/type-tests/exact-optional-property-types.tsx new file mode 100644 index 000000000..625111f85 --- /dev/null +++ b/packages/streamdown/type-tests/exact-optional-property-types.tsx @@ -0,0 +1,49 @@ +/** + * Type-level regression test for `exactOptionalPropertyTypes` support. + * + * This file is type-checked with `tsconfig.exact-optional.json` + * (`pnpm check-types:exact-optional`) against the built declarations, with + * `exactOptionalPropertyTypes` enabled. It is never executed. + */ +import { Streamdown } from "../dist/index"; + +interface MessageProps { + caret?: "block" | "circle"; + className?: string; + codeBlockMaxHeight?: number | string; + dir?: "auto" | "ltr" | "rtl"; + isAnimating?: boolean; + lineNumbers?: boolean; + mode?: "static" | "streaming"; + parseIncompleteMarkdown?: boolean; + prefix?: string; + tableMaxHeight?: number | string; + text: string; +} + +// Forwarding possibly-`undefined` optional props must type-check. +export const Message = (props: MessageProps) => ( + + {props.text} + +); From d9e0a73d04cf36af8b0ed89d72b44a6e2629c464 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Thu, 29 Oct 2026 03:04:23 +0100 Subject: [PATCH 2/2] ci: run exactOptionalPropertyTypes type test --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 37befff73..892f27f76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,9 @@ jobs: - name: Build Packages run: pnpm build:packages + - name: Check exactOptionalPropertyTypes compatibility + run: pnpm --filter streamdown exec tsc -p tsconfig.exact-optional.json + - name: Build Website run: pnpm build --filter website