Skip to content

Streamdown crashes on iOS 16 / Safari < 16.3 due to lookbehind regex in remend and mdast-util-gfm-autolink-literal #519

@zhangeyuting

Description

@zhangeyuting

Summary

Streamdown fails to initialize on iOS 16.0–16.2 (and any Safari/WebKit < 16.3) because two regexes in the dependency chain use lookbehind assertions (?<=...) / (?<!...), which JSCore on those versions does not support. The error is thrown at module top-level evaluation, with no try/catch, so the entire app chunk fails to export and the page goes blank.

Reproduction

Render any markdown with <Streamdown> in a Next.js (or any bundler that ships the package as ESM/CJS into the browser) and load it on an iOS 16.0–16.2 device or Safari Technology Preview pinned to that JSC version.

import { Streamdown } from 'streamdown';

export default function Page() {
  return <Streamdown>hello</Streamdown>;
}

Console:

SyntaxError: Invalid regular expression: invalid group specifier name
  RegExp
  (anonymous) — _app-<hash>.js:173:1032
  __webpack_require__ — webpack-<hash>.js:1:148
  ...
Unhandled Promise Rejection: TypeError: undefined is not an object (evaluating 'e.getInitialProps')

The second error is downstream: the chunk's module factory threw before assigning exports, so Next.js sees e === undefined when reading getInitialProps.

Affected versions

  • streamdown@2.5.0 (latest at time of writing)
  • remend@1.3.0 (bundled via streamdown)
  • mdast-util-gfm-autolink-literal@2.0.1 (pulled in by streamdown → remark-gfm@4)

Root cause — two specific lines

1. remend/dist/index.js (top-level, no try/catch)

var Gn = new RegExp(
  "(?<=[\\p{L}\\p{N}_])~(?!~)(?=[\\p{L}\\p{N}_])",
  "gu"
);

Used by the singleTilde handler to escape stray ~ characters between word chars. Since the RegExp constructor runs at module evaluation, the error throws before any user code executes and there is no fallback.

2. mdast-util-gfm-autolink-literal/lib/index.js:135

[/(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail]

Used inside transformGfmAutolinkLiterals to find bare email addresses. The regex literal is compiled when the module loads (or wrapped into new RegExp(...) by some bundlers/minifiers — Next.js/SWC in our build ended up calling the constructor, which is why we see the RegExp frame in the stack), and again there is no fallback.

Note: streamdown also bundles marked@15, which uses new RegExp("(?<=1)(?<!1)") for feature detection — that one is wrapped in try/catch, so it is not the culprit. It only proves that streamdown's own author is aware lookbehind isn't universally supported.

Why react-markdown users don't hit this

react-markdown@8 resolves to remark-gfm@3mdast-util-gfm-autolink-literal@1.x, which does not use lookbehind. streamdown jumped to remark-gfm@4mdast-util-gfm-autolink-literal@2.x and additionally introduced remend, both of which adopted lookbehind. So this regresses any project migrating from react-markdown to streamdown.

Suggested fixes

Both lookbehinds can be rewritten without lookbehind while preserving semantics:

remend — change the lookbehind to a consuming capture:

-var Gn = new RegExp(
-  "(?<=[\\p{L}\\p{N}_])~(?!~)(?=[\\p{L}\\p{N}_])",
-  "gu"
-);
-tn = n => !n || typeof n !== "string" || !n.includes("~")
-  ? n
-  : n.replace(Gn, (r, e) => c(n, e) ? r : "\\~");
+var Gn = new RegExp(
+  "([\\p{L}\\p{N}_])~(?!~)(?=[\\p{L}\\p{N}_])",
+  "gu"
+);
+tn = n => !n || typeof n !== "string" || !n.includes("~")
+  ? n
+  : n.replace(Gn, (r, p, e) => c(n, e + 1) ? r : p + "\\~");

The captured left word char is written back; c() is offset by 1 so it still points at the ~. Semantics are identical.

mdast-util-gfm-autolink-literal — drop the lookbehind, rely on the existing previous() check:

-[/(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail]
+[/([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail]

findEmail already calls previous(match, true) which inspects match.input[match.index - 1] for ^ | whitespace | punctuation. mdast-util-find-and-replace advances lastIndex by 1 on false (lib/index.js:158-162), so no infinite loop. Only edge case: lookbehind also accepted \p{S} (symbols, e.g. ), which previous() does not — negligible in practice, and worth the broad-browser support.

If you'd rather not change the regex semantics, wrap both call sites in try/catch and fall back to a non-lookbehind version so the page at least loads.

Workaround for affected projects (now)

Until upstream lands a fix, this is patchable in userland with patch-package:

npm i -D patch-package
# edit node_modules/remend/dist/index.js + node_modules/.../mdast-util-gfm-autolink-literal/lib/index.js with the diffs above
npx patch-package remend
npx patch-package streamdown/mdast-util-gfm-autolink-literal
# add "postinstall": "patch-package" to package.json

Verified to fix the white-screen on iOS 16.0/16.1/16.2.

Environment

  • Streamdown: 2.5.0
  • Next.js: 15.3.8 (Pages Router, SWC compiler)
  • Browsers confirmed broken: Safari 16.0, 16.1, 16.2 (iOS + macOS), WKWebView on iOS 16.0–16.2
  • Browsers confirmed working: Safari 16.4+, all Chromium, all Firefox
  • Lookbehind support reference: https://caniuse.com/js-regexp-lookbehind (Safari shipped support in 16.4, March 2023)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions