Skip to content

MarkdownA data-incomplete handling is unreachable: the built-in sanitize schema strips streamdown:incomplete-link, so default linkMode: "protocol" always degrades to the harden "[blocked]" replacement #551

Description

@hrminator

Summary

The graceful-render branch for incomplete streaming links in MarkdownA (data-incomplete={isIncomplete} keyed on href === "streamdown:incomplete-link") is structurally dead code: the sentinel URL that remend injects can never reach the component, because Streamdown's own default rehype pipeline strips it first. The observable result is that the DEFAULT remend.linkMode: "protocol" flashes {label} [blocked] on every incomplete link mid-stream — the exact symptom of #156 — and the intended data-incomplete styling path never fires.

This is distinct from #156 / #165 / #301 (all closed): those report the flash or ask for an escape hatch (resolved by the opt-in remend.linkMode: "text-only"). This report is that the default mode's own healing mechanism is internally unreachable, so the default is structurally broken rather than merely opinionated.

Mechanism (all refs on main, reproduces on 2.5.0)

  1. remend (string level, streaming mode) completes a partial [label](ur… or see [the docs tail into [label](streamdown:incomplete-link)linkMode: "protocol" default.
  2. rehype-sanitize runs with the built-in schema (packages/streamdown/index.tsx ~L239-247), which extends defaultSchema.protocols.href with only "tel". defaultSchema.protocols.href is ['http','https','irc','ircs','mailto','xmpp']streamdown: is not in the list, so the sentinel href is deleted.
  3. rehype-harden (pipeline order raw → sanitize → harden, index.tsx ~L251-257) then sees an href-less <a>; transformUrl(undefined, …) returns null → the default indicator block policy replaces the anchor with <span title="Blocked URL: undefined" …>{label} [blocked]</span>. (Harden's own allowedProtocols: ["*"] would have allowed the sentinel — the block trigger is the sanitize-deleted href, not the protocol.)
  4. By render time the node is a <span>, so components.a / MarkdownA is never invoked for it — the isIncomplete branch (packages/streamdown/lib/components.tsx ~L273, data-incomplete at ~L322/L344) cannot execute for streamed-incomplete links.

There is also no user-side escape at the schema layer: allowedTags merges tagNames/attributes only (no protocols extension), and replacing rehypePlugins wholesale disables the allowedTags mechanism.

Suggested fix (either restores the designed behavior)

  • Add the sentinel protocol to the built-in sanitize schema: href: [...(defaultSchema.protocols?.href ?? []), "tel", "streamdown"] — one line; harden already allowlists * protocols, and MarkdownA renders the sentinel case with data-incomplete + no navigable href, so nothing user-navigable leaks; or
  • flip the default linkMode to "text-only" so the default never emits a sentinel it immediately destroys.

Repro

import { Streamdown } from "streamdown"; // 2.5.0
renderToStaticMarkup(<Streamdown mode="streaming">{"see [the docs"}</Streamdown>);
// → …see <span title="Blocked URL: undefined" class="text-gray-500">the docs [blocked]</span>
// expected (per MarkdownA's isIncomplete branch): an <a data-incomplete="true"> styled as pending

Workaround we ship in production: remend={{ linkMode: "text-only" }} — works as documented; this issue is only about the default mode's unreachable branch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions