Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flowchart-deep-indent-parse-perf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

perf: avoid quadratic parse cost on deeply-indented diagrams (~1.4 s → ~30 ms on a pathological fixture)
5 changes: 5 additions & 0 deletions .changeset/silent-laws-find.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

fix: bundle `fastdom` to fix issues with global `define`
9 changes: 9 additions & 0 deletions e2e/platform/iife.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@

<div id="d2"></div>

<script>
/**
* Mock global to break AMD imports. Mermaid should only use IIFE.
*/
function define() {
return 'hello';
}
</script>

<script src="/mermaid.min.js"></script>
<script>
mermaid.initialize({
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
}
},
"patchedDependencies": {
"fastdom": "patches/fastdom.patch",
"roughjs": "patches/roughjs.patch"
},
"onlyBuiltDependencies": [
Expand Down
2 changes: 1 addition & 1 deletion packages/mermaid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@
"dayjs": "^1.11.21",
"dompurify": "^3.4.12",
"es-toolkit": "^1.45.1",
"fastdom": "1.0.12",
"katex": "^0.16.47",
"khroma": "^2.1.0",
"marked": "^16.3.0",
Expand Down Expand Up @@ -111,6 +110,7 @@
"chokidar": "3.6.0",
"concurrently": "^9.2.4",
"csstree-validator": "^4.0.1",
"fastdom": "1.0.12",
"globby": "^14.1.0",
"jison": "^0.4.18",
"js-base64": "^3.7.8",
Expand Down
7 changes: 2 additions & 5 deletions packages/mermaid/src/diagram-api/detectType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
DiagramLoader,
ExternalDiagramDefinition,
} from './types.js';
import { anyCommentRegex, directiveRegex, frontMatterRegex } from './regexes.js';
import { directiveRegex, frontMatterRegex, stripAnyComments } from './regexes.js';
import { UnknownDiagramError } from '../errors.js';

export const detectors: Record<string, DetectorRecord> = {};
Expand Down Expand Up @@ -34,10 +34,7 @@ export const detectors: Record<string, DetectorRecord> = {};
* @returns A graph definition key
*/
export const detectType = function (text: string, config?: MermaidConfig): string {
text = text
.replace(frontMatterRegex, '')
.replace(directiveRegex, '')
.replace(anyCommentRegex, '\n');
text = stripAnyComments(text.replace(frontMatterRegex, '').replace(directiveRegex, ''));
for (const [key, { detector }] of Object.entries(detectors)) {
const diagram = detector(text, config);
if (diagram) {
Expand Down
76 changes: 76 additions & 0 deletions packages/mermaid/src/diagram-api/regexes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { anyCommentRegex, stripAnyComments } from './regexes.js';

// The exported `anyCommentRegex` is the published, pre-optimization pattern and serves as the
// equivalence oracle: `stripAnyComments` must strip comments byte-for-byte identically to
// `replace(anyCommentRegex, '\n')` — the only difference is avoiding O(whitespace²) backtracking
// on deeply-indented input, not the matched text.

// Fresh regex per call so the global `lastIndex` never leaks between assertions.
const legacyStrip = (text: string) =>
text.replace(new RegExp(anyCommentRegex.source, anyCommentRegex.flags), '\n');

const CORPUS: string[] = [
'%% a line comment\n',
'A\n%% comment between\nB\n',
' %% indented comment\n',
'A --> B %% inline comment\n',
'A\n\n%% comment after a blank line\n',
'%% first\n%% second\n',
'graph TD\n A %% trailing\n B\n',
' \n %% comment after whitespace-only line\n',
'no comments here\nA-->B\nC-->D\n',
'A\n%% comment with no trailing newline',
' \n%% comment at col0 after a ws line\n',
'X\n \n %% deep\n \nY\n',
'%%nospaceaftermarker\n',
'a%%b\n', // `%%` mid-token, no leading whitespace
'graph TD\n%% top\nA-->B %% mid\n%% bottom\n',
'\t\t%% tab indented\n',
'flowchart LR\n subgraph S\n %% inside subgraph\n a-->b\n end\n',
'',
'\n\n\n',
'%%\n', // empty comment
// The two shapes CodeQL and the CWE-1333 review flagged. Small instances here: the point is
// that the scanner still agrees with the released regex on them, not how fast it is.
('\n' + ' '.repeat(4)).repeat(20),
'%%' + 'x%%'.repeat(20),
'%%' + 'x%%'.repeat(20) + '\n',
' \n \n %% after blank indented lines\n',
];

describe('stripAnyComments', () => {
it('strips comments byte-identically to replace(anyCommentRegex, "\\n")', () => {
for (const input of CORPUS) {
expect(stripAnyComments(input)).toBe(legacyStrip(input));
}
});

it.each([
['all-whitespace lines', (n: number) => ('\n' + ' '.repeat(4)).repeat(n)],
['`%%` runs with no terminating newline', (n: number) => '%%' + 'x%%'.repeat(n)],
['deep indents', (n: number) => (' '.repeat(400) + 'classDef x fill:#fff\n').repeat(n)],
])('scales linearly on %s', (_label, build) => {
// Scaling, not a wall-clock bound. The previous version of this test asserted "under 200ms"
// on one fixed input, which a quadratic implementation passes comfortably — and did, for
// both shapes above. Doubling the input should roughly double the work; quadratic would
// quadruple it.
const measure = (n: number) => {
const input = build(n);
// Warm up so the first call does not carry compilation cost into the ratio.
stripAnyComments(input);
const t0 = performance.now();
for (let i = 0; i < 5; i++) {
stripAnyComments(input);
}
return (performance.now() - t0) / 5;
};

const small = measure(4000);
const large = measure(16000);

// 4x the input. Linear predicts ~4x, quadratic ~16x. The bar is set at 8 so ordinary timing
// noise on a loaded machine cannot fail it while a return to quadratic still does.
expect(large / Math.max(small, 0.01)).toBeLessThan(8);
});
});
61 changes: 61 additions & 0 deletions packages/mermaid/src/diagram-api/regexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,65 @@ export const frontMatterRegex = /^([^\S\n\r]*)-{3}\s*[\n\r](.*?)[\n\r]\1-{3}\s*[
export const directiveRegex =
/%{2}{\s*(?:(\w+)\s*:|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi;

// Kept byte-for-byte as released: it is part of the published surface, and consumers replace with
// `'\n'` — a pattern change (capture groups, lookbehind) would silently change their output or throw
// at module load on older engines. It backtracks quadratically on deep indents, so internal hot
// paths must use `stripAnyComments` below instead of replacing with this regex directly.
export const anyCommentRegex = /\s*%%.*\n/gm;

/** One character, tested against the same class `\s` means inside `anyCommentRegex`. */
const whitespace = /\s/;

/**
* Strip `%%` comment runs exactly like `text.replace(anyCommentRegex, '\n')`, in linear time.
*
* A scanner rather than a regex, because no variant of this pattern is linear. Every regex form
* carries a `\s*` that can cross newlines, and `/m` gives the engine a candidate start at each
* line, so an all-whitespace document has the match attempt rescan the remaining run once per
* line. Two shapes are quadratic in the released pattern and in the guarded `(^|\S)\s*%%.*\n`
* that replaced it, both inside the default 50k `maxTextSize`:
*
* ```
* ('\n' + ' '.repeat(4)).repeat(10_000) all whitespace, many lines 256ms
* '%%' + 'x%%'.repeat(16_000) no terminating newline 339ms
* ```
*
* against 0.1ms for ordinary diagram text of the same size. The guard cut the constant roughly
* 40x but left the exponent alone, which is what CodeQL and the CWE-1333 review both caught.
*
* The scan walks forward once. For each `%%` it takes the line's terminating newline as the end
* of the match — `.` never matches a newline, so the regex ends at that same character — and
* extends left over the preceding whitespace run, never past the previous match. Each character
* is visited at most twice, so the work is linear in the input and independent of how the
* whitespace is arranged.
*
* A `%%` with no newline after it is left alone, because `%%.*\n` cannot match without one.
*/
export const stripAnyComments = (text: string): string => {
let out = '';
// Everything before this index has been emitted or dropped; also the floor for the leftward
// whitespace scan, which is what `lastIndex` does for the global regex.
let consumed = 0;
let searchFrom = 0;

while (searchFrom < text.length) {
const marker = text.indexOf('%%', searchFrom);
if (marker === -1) {
break;
}
const lineEnd = text.indexOf('\n', marker);
if (lineEnd === -1) {
// No newline left in the string, so this `%%` and every later one cannot match.
break;
}
let start = marker;
while (start > consumed && whitespace.test(text[start - 1])) {
start--;
}
out += text.slice(consumed, start) + '\n';
consumed = lineEnd + 1;
searchFrom = consumed;
}

return out + text.slice(consumed);
};
2 changes: 1 addition & 1 deletion packages/mermaid/src/diagrams/flowchart/parser/flow.jison
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ that id.

"\"" return 'QUOTE';
(\r?\n)+ return 'NEWLINE';
\s return 'SPACE';
[^\S\n\r]+ return 'SPACE';
<<EOF>> return 'EOF';

/lex
Expand Down
29 changes: 14 additions & 15 deletions packages/mermaid/src/rendering-util/fastdom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,19 @@ import fastdomPromised from 'fastdom/extensions/fastdom-promised.js';
* const bbox = await fastdom.measure(() => div.node()!.getBoundingClientRect());
* ```
*/
const fastdom = // @ts-expect-error -- fastdom types aren't yet ESM-compatible, we need this hack
(fastdomModule as typeof fastdomModule.default)
.extend({
/**
* `requestAnimationFrame` is too slow compared to `queueMicrotask`.
*/
raf(cb: () => void) {
if (typeof queueMicrotask === 'function') {
queueMicrotask(cb);
} else {
setTimeout(cb, 0);
}
},
})
.extend(fastdomPromised);
const fastdom = fastdomModule
.extend({
/**
* `requestAnimationFrame` is too slow compared to `queueMicrotask`.
*/
raf(cb: () => void) {
if (typeof queueMicrotask === 'function') {
queueMicrotask(cb);
} else {
setTimeout(cb, 0);
}
},
})
.extend(fastdomPromised);

export default fastdom;
131 changes: 131 additions & 0 deletions patches/fastdom.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
diff --git a/extensions/fastdom-promised.d.ts b/extensions/fastdom-promised.d.ts
index 236cf0fa6e9d04b0344ca87f7b5059ab9c43fa75..fdb6a33dcdfc4d9adc8a7dc6f5ba56a5bbfa762d 100644
--- a/extensions/fastdom-promised.d.ts
+++ b/extensions/fastdom-promised.d.ts
@@ -5,4 +5,8 @@ declare namespace FastdomPromised {
export function mutate<T extends () => void>(task: T, context?: any): Promise<ReturnType<T>>;
}

-export = FastdomPromised;
+export default FastdomPromised;
+export const clear: typeof FastdomPromised.clear;
+export const initialize: typeof FastdomPromised.initialize;
+export const measure: typeof FastdomPromised.measure;
+export const mutate: typeof FastdomPromised.mutate;
diff --git a/extensions/fastdom-promised.js b/extensions/fastdom-promised.js
index cc6acca5d99d330b3afd7bc2b247a82a44ffb959..5cbc1e33a5d139bfc09eeccad2232216735be50b 100644
--- a/extensions/fastdom-promised.js
+++ b/extensions/fastdom-promised.js
@@ -1,5 +1,3 @@
-!(function() {
-
/**
* Wraps fastdom in a Promise API
* for improved control-flow.
@@ -69,9 +67,5 @@ function create(promised, type, fn, ctx) {
return promise;
}

-// Expose to CJS, AMD or global
-if ((typeof define)[0] == 'f') define(function() { return exports; });
-else if ((typeof module)[0] == 'o') module.exports = exports;
-else window.fastdomPromised = exports;
-
-})();
\ No newline at end of file
+export const { initialize, mutate, measure, clear } = exports;
+export default exports;
diff --git a/extensions/fastdom-sandbox.js b/extensions/fastdom-sandbox.js
index b6c778267800aedef2d3639fec1d51df6b3e754a..5add168abf967e08bc9479627ec1c264a9e1c7a0 100644
--- a/extensions/fastdom-sandbox.js
+++ b/extensions/fastdom-sandbox.js
@@ -1,5 +1,3 @@
-(function(exports) {
-
/**
* Mini logger
*
@@ -41,7 +39,7 @@ var debug = 0 ? console.log.bind(console, '[fastdom-sandbox]') : function() {};
* @return {Sandbox}
* @public
*/
-exports.sandbox = function() {
+export const sandbox = function() {
return new Sandbox(this.fastdom);
};

@@ -135,13 +133,3 @@ function remove(array, item) {
var index = array.indexOf(item);
return !!~index && !!array.splice(index, 1);
}
-
-/**
- * Expose
- */
-
-if ((typeof define)[0] == 'f') define(function() { return exports; });
-else if ((typeof module)[0] == 'o') module.exports = exports;
-else window.fastdomSandbox = exports;
-
-})({});
diff --git a/fastdom.js b/fastdom.js
index 5ef81a1ced998d3dc690552b7212288b477557e3..e487f46d794035406d81134eb326bf591a7ac700 100644
--- a/fastdom.js
+++ b/fastdom.js
@@ -1,4 +1,4 @@
-!(function(win) {
+export default (function(win) {

/**
* FastDom
@@ -237,8 +237,5 @@ function mixin(target, source) {
// one instance of `FastDom` in an app
var exports = win.fastdom = (win.fastdom || new FastDom()); // jshint ignore:line

-// Expose to CJS & AMD
-if ((typeof define) == 'function') define(function() { return exports; });
-else if ((typeof module) == 'object') module.exports = exports;
-
+return exports;
})( typeof window !== 'undefined' ? window : typeof this != 'undefined' ? this : globalThis);
diff --git a/package.json b/package.json
index 33a57921b45a0ad1fbf21faacb660f8cd40275b4..3887737b37aa63327b18e1348bf37e65f93a062d 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"watch": "webpack -w"
},
"homepage": "https://github.com/wilsonpage/fastdom",
+ "type": "module",
"author": {
"name": "Wilson Page",
"email": "wilsonpage@me.com"
diff --git a/src/fastdom-strict.js b/src/fastdom-strict.js
index d26b86f74afbffca939b85a86696b0ddf4f64498..b97a0e53f36b973a106d2bc762d4641b7514a08b 100644
--- a/src/fastdom-strict.js
+++ b/src/fastdom-strict.js
@@ -1,7 +1,5 @@
-'use strict';
-
-var strictdom = require('strictdom');
-var fastdom = require('../fastdom');
+import strictdom from 'strictdom';
+import originalFastdom from '../fastdom.js';

/**
* Mini logger
@@ -17,7 +15,7 @@ var debug = 0 ? console.log.bind(console, '[fastdom-strict]') : function() {};
*/
var enabled = false;

-window.fastdom = module.exports = fastdom.extend({
+export const fastdom = globalThis.fastdom = originalFastdom.extend({
measure: function(fn, ctx) {
debug('measure');
var task = !ctx ? fn : fn.bind(ctx);
@@ -48,4 +46,4 @@ window.fastdom = module.exports = fastdom.extend({
});

// turn on strict-mode
-window.fastdom.strict(true);
+fastdom.strict(true);
Loading
Loading