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@3 → mdast-util-gfm-autolink-literal@1.x, which does not use lookbehind. streamdown jumped to remark-gfm@4 → mdast-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)
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 notry/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.Console:
The second error is downstream: the chunk's module factory threw before assigning exports, so Next.js sees
e === undefinedwhen readinggetInitialProps.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 bystreamdown → remark-gfm@4)Root cause — two specific lines
1.
remend/dist/index.js(top-level, notry/catch)Used by the
singleTildehandler to escape stray~characters between word chars. Since theRegExpconstructor 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:135Used inside
transformGfmAutolinkLiteralsto find bare email addresses. The regex literal is compiled when the module loads (or wrapped intonew RegExp(...)by some bundlers/minifiers — Next.js/SWC in our build ended up calling the constructor, which is why we see theRegExpframe in the stack), and again there is no fallback.Why
react-markdownusers don't hit thisreact-markdown@8resolves toremark-gfm@3→mdast-util-gfm-autolink-literal@1.x, which does not use lookbehind.streamdownjumped toremark-gfm@4→mdast-util-gfm-autolink-literal@2.xand additionally introducedremend, both of which adopted lookbehind. So this regresses any project migrating fromreact-markdowntostreamdown.Suggested fixes
Both lookbehinds can be rewritten without lookbehind while preserving semantics:
remend— change the lookbehind to a consuming capture: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 existingprevious()check:findEmailalready callsprevious(match, true)which inspectsmatch.input[match.index - 1]for^ | whitespace | punctuation.mdast-util-find-and-replaceadvanceslastIndexby 1 onfalse(lib/index.js:158-162), so no infinite loop. Only edge case: lookbehind also accepted\p{S}(symbols, e.g.→), whichprevious()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/catchand 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:Verified to fix the white-screen on iOS 16.0/16.1/16.2.
Environment