perf(ui): batch Mermaid viewer initialization
This commit is contained in:
@@ -227,6 +227,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.5.0",
|
"eslint-plugin-react-refresh": "^0.5.0",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"happy-dom": "^18.0.1",
|
||||||
"nodemon": "^3.1.7",
|
"nodemon": "^3.1.7",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
@@ -1451,6 +1452,8 @@
|
|||||||
|
|
||||||
"@types/vscode": ["@types/vscode@1.109.0", "", {}, "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw=="],
|
"@types/vscode": ["@types/vscode@1.109.0", "", {}, "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw=="],
|
||||||
|
|
||||||
|
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
|
||||||
|
|
||||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
|
||||||
@@ -2139,6 +2142,8 @@
|
|||||||
|
|
||||||
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||||
|
|
||||||
|
"happy-dom": ["happy-dom@18.0.1", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA=="],
|
||||||
|
|
||||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||||
|
|
||||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
@@ -3351,7 +3356,7 @@
|
|||||||
|
|
||||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||||
|
|
||||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||||
|
|
||||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
@@ -3589,6 +3594,8 @@
|
|||||||
|
|
||||||
"cheerio/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
|
"cheerio/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
|
||||||
|
|
||||||
|
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
@@ -3629,6 +3636,8 @@
|
|||||||
|
|
||||||
"globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
|
"globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
|
||||||
|
|
||||||
|
"happy-dom/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||||
|
|
||||||
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
|
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
|
||||||
|
|
||||||
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||||
@@ -3815,6 +3824,8 @@
|
|||||||
|
|
||||||
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||||
|
|
||||||
|
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||||
|
|
||||||
"micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
"micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.5.0",
|
"eslint-plugin-react-refresh": "^0.5.0",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"happy-dom": "^18.0.1",
|
||||||
"nodemon": "^3.1.7",
|
"nodemon": "^3.1.7",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { afterAll, describe, expect, test } from 'bun:test';
|
||||||
|
import { Window } from 'happy-dom';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
|
||||||
|
type OperationCounts = {
|
||||||
|
innerHTMLWrites: number;
|
||||||
|
querySelectorAllCalls: number;
|
||||||
|
appendCalls: number;
|
||||||
|
replaceCalls: number;
|
||||||
|
removeCalls: number;
|
||||||
|
getBoundingClientRectCalls: number;
|
||||||
|
viewBoxWrites: number;
|
||||||
|
resizeObserverCreates: number;
|
||||||
|
resizeObserverObserveCalls: number;
|
||||||
|
geometrySequence: Array<'read' | 'write'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FixtureMetrics = OperationCounts & {
|
||||||
|
renderers: number;
|
||||||
|
markdownBlocks: number;
|
||||||
|
mermaidBlocks: number;
|
||||||
|
mermaidRenderedCount: number;
|
||||||
|
mermaidSvgCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixture = [
|
||||||
|
'# Synthetic mount fixture',
|
||||||
|
'',
|
||||||
|
'A paragraph with **bold text**, a table, and a stable link.',
|
||||||
|
'',
|
||||||
|
'| name | value |',
|
||||||
|
'| --- | ---: |',
|
||||||
|
'| alpha | 1 |',
|
||||||
|
'| beta | 2 |',
|
||||||
|
'',
|
||||||
|
'```typescript',
|
||||||
|
'const answer = 42;',
|
||||||
|
'console.log(answer);',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'```mermaid',
|
||||||
|
'graph TD',
|
||||||
|
' A[Start] --> B[Finish]',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'```mermaid',
|
||||||
|
'graph LR',
|
||||||
|
' Client[Client] --> Server[Server]',
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const fixtureWorkload = {
|
||||||
|
rendererCount: 3,
|
||||||
|
domBlocksPerRenderer: 1,
|
||||||
|
mermaidBlocksPerRenderer: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
let windowInstance: Window;
|
||||||
|
let previousGlobals: Map<string, PropertyDescriptor | undefined>;
|
||||||
|
let activeCounts: OperationCounts | null = null;
|
||||||
|
let animationFrameQueue: FrameRequestCallback[] = [];
|
||||||
|
let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null;
|
||||||
|
let MarkdownRenderer: React.ComponentType<{
|
||||||
|
content: string;
|
||||||
|
messageId: string;
|
||||||
|
isAnimated?: boolean;
|
||||||
|
enableFileReferences?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const makeCounts = (): OperationCounts => ({
|
||||||
|
innerHTMLWrites: 0,
|
||||||
|
querySelectorAllCalls: 0,
|
||||||
|
appendCalls: 0,
|
||||||
|
replaceCalls: 0,
|
||||||
|
removeCalls: 0,
|
||||||
|
getBoundingClientRectCalls: 0,
|
||||||
|
viewBoxWrites: 0,
|
||||||
|
resizeObserverCreates: 0,
|
||||||
|
resizeObserverObserveCalls: 0,
|
||||||
|
geometrySequence: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const installGlobal = (name: string, value: Window[keyof Window]): void => {
|
||||||
|
previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||||
|
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForSettledEffects = async (): Promise<void> => {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||||
|
await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushAnimationFrame = async (): Promise<void> => {
|
||||||
|
const callbacks = animationFrameQueue;
|
||||||
|
animationFrameQueue = [];
|
||||||
|
await act(async () => {
|
||||||
|
for (const callback of callbacks) callback(windowInstance.performance.now());
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushDeferredMermaidInitialization = async (): Promise<void> => {
|
||||||
|
await flushAnimationFrame();
|
||||||
|
await flushAnimationFrame();
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountFixture = async (rendererCount: number): Promise<{
|
||||||
|
root: Root;
|
||||||
|
host: HTMLDivElement;
|
||||||
|
operations: OperationCounts;
|
||||||
|
counts: FixtureMetrics;
|
||||||
|
}> => {
|
||||||
|
const counts = makeCounts();
|
||||||
|
activeCounts = counts;
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.replaceChildren(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<>
|
||||||
|
{Array.from({ length: rendererCount }, (_, index) => (
|
||||||
|
<MarkdownRenderer
|
||||||
|
key={`fixture-${index}`}
|
||||||
|
content={fixture}
|
||||||
|
messageId={`fixture-message-${index}`}
|
||||||
|
isAnimated={false}
|
||||||
|
enableFileReferences={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await waitForSettledEffects();
|
||||||
|
});
|
||||||
|
await act(async () => waitForSettledEffects());
|
||||||
|
|
||||||
|
const mermaidBlocks = host.querySelectorAll('[data-markdown="mermaid-block"]').length;
|
||||||
|
const mermaidRenderedCount = host.querySelectorAll('[data-mermaid-render]').length;
|
||||||
|
const mermaidSvgCount = host.querySelectorAll('[data-markdown="mermaid"] svg').length;
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
host,
|
||||||
|
operations: counts,
|
||||||
|
counts: {
|
||||||
|
...counts,
|
||||||
|
renderers: rendererCount,
|
||||||
|
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||||
|
mermaidBlocks,
|
||||||
|
mermaidRenderedCount,
|
||||||
|
mermaidSvgCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const runFixture = async (rendererCount: number): Promise<FixtureMetrics> => {
|
||||||
|
const { root, host, operations } = await mountFixture(rendererCount);
|
||||||
|
await flushDeferredMermaidInitialization();
|
||||||
|
const counts: FixtureMetrics = {
|
||||||
|
...operations,
|
||||||
|
renderers: rendererCount,
|
||||||
|
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||||
|
mermaidBlocks: host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||||
|
mermaidRenderedCount: host.querySelectorAll('[data-mermaid-render]').length,
|
||||||
|
mermaidSvgCount: host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||||
|
};
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
return counts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializePerformanceDom = async (): Promise<void> => {
|
||||||
|
windowInstance = new Window({ url: 'http://localhost/' });
|
||||||
|
windowInstance.document.write('<!doctype html><html><head></head><body></body></html>');
|
||||||
|
windowInstance.document.close();
|
||||||
|
previousGlobals = new Map();
|
||||||
|
installGlobal('window', windowInstance);
|
||||||
|
installGlobal('document', windowInstance.document);
|
||||||
|
installGlobal('navigator', windowInstance.navigator);
|
||||||
|
installGlobal('customElements', windowInstance.customElements);
|
||||||
|
for (const name of ['Document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Text', 'NodeFilter', 'MutationObserver', 'DOMParser', 'XMLSerializer', 'HTMLAnchorElement', 'HTMLButtonElement']) {
|
||||||
|
// SAFETY: these names are the DOM constructors installed by this happy-dom Window.
|
||||||
|
const globalValue = windowInstance[name as keyof Window];
|
||||||
|
if (globalValue === undefined) throw new Error(`happy-dom global is unavailable: ${name}`);
|
||||||
|
installGlobal(name, globalValue);
|
||||||
|
}
|
||||||
|
Object.defineProperty(windowInstance, 'matchMedia', { configurable: true, value: () => ({ matches: false, media: '', onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }) });
|
||||||
|
Object.defineProperty(windowInstance, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => {
|
||||||
|
animationFrameQueue.push(callback);
|
||||||
|
return animationFrameQueue.length;
|
||||||
|
} });
|
||||||
|
Object.defineProperty(windowInstance, 'cancelAnimationFrame', { configurable: true, value: () => undefined });
|
||||||
|
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
|
||||||
|
const elementPrototype = Element.prototype;
|
||||||
|
const nodePrototype = Node.prototype;
|
||||||
|
const documentPrototype = Document.prototype;
|
||||||
|
const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
|
||||||
|
if (!innerHTMLDescriptor?.set || !innerHTMLDescriptor.get) throw new Error('happy-dom innerHTML descriptor unavailable');
|
||||||
|
Object.defineProperty(Element.prototype, 'innerHTML', {
|
||||||
|
configurable: true,
|
||||||
|
get: innerHTMLDescriptor.get,
|
||||||
|
set(value: string) {
|
||||||
|
if (activeCounts) activeCounts.innerHTMLWrites += 1;
|
||||||
|
innerHTMLDescriptor.set?.call(this, value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const originalQuerySelectorAll = elementPrototype.querySelectorAll;
|
||||||
|
Object.defineProperty(elementPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||||
|
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||||
|
return originalQuerySelectorAll.call(this, selectors);
|
||||||
|
} });
|
||||||
|
const originalDocumentQuerySelectorAll = documentPrototype.querySelectorAll;
|
||||||
|
Object.defineProperty(documentPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||||
|
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||||
|
return originalDocumentQuerySelectorAll.call(this, selectors);
|
||||||
|
} });
|
||||||
|
const originalAppendChild = nodePrototype.appendChild;
|
||||||
|
Object.defineProperty(nodePrototype, 'appendChild', { configurable: true, value: function (node: Node): Node {
|
||||||
|
if (activeCounts) activeCounts.appendCalls += 1;
|
||||||
|
return originalAppendChild.call(this, node);
|
||||||
|
} });
|
||||||
|
const originalReplaceWith = elementPrototype.replaceWith;
|
||||||
|
Object.defineProperty(elementPrototype, 'replaceWith', { configurable: true, value: function (...nodes: (Node | string)[]): void {
|
||||||
|
if (activeCounts) activeCounts.replaceCalls += 1;
|
||||||
|
return originalReplaceWith.apply(this, nodes);
|
||||||
|
} });
|
||||||
|
const originalRemove = elementPrototype.remove;
|
||||||
|
Object.defineProperty(elementPrototype, 'remove', { configurable: true, value: function (): void {
|
||||||
|
if (activeCounts) activeCounts.removeCalls += 1;
|
||||||
|
return originalRemove.call(this);
|
||||||
|
} });
|
||||||
|
const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect;
|
||||||
|
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect {
|
||||||
|
if (activeCounts) {
|
||||||
|
activeCounts.getBoundingClientRectCalls += 1;
|
||||||
|
activeCounts.geometrySequence.push('read');
|
||||||
|
}
|
||||||
|
return originalGetBoundingClientRect.call(this);
|
||||||
|
} });
|
||||||
|
const svgSetAttribute = SVGElement.prototype.setAttribute;
|
||||||
|
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
|
||||||
|
if (name === 'viewBox' && activeCounts) {
|
||||||
|
activeCounts.viewBoxWrites += 1;
|
||||||
|
activeCounts.geometrySequence.push('write');
|
||||||
|
}
|
||||||
|
return svgSetAttribute.call(this, name, value);
|
||||||
|
} });
|
||||||
|
class CountingResizeObserver {
|
||||||
|
constructor(callback: (entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) {
|
||||||
|
if (activeCounts) activeCounts.resizeObserverCreates += 1;
|
||||||
|
notifyResize = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(): void {
|
||||||
|
if (activeCounts) activeCounts.resizeObserverObserveCalls += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve(): void {}
|
||||||
|
|
||||||
|
disconnect(): void {}
|
||||||
|
}
|
||||||
|
installGlobal('ResizeObserver', CountingResizeObserver);
|
||||||
|
|
||||||
|
const fakeState = {
|
||||||
|
openContextPreview: () => undefined,
|
||||||
|
codeBlockLineWrap: false,
|
||||||
|
mermaidRenderingMode: 'svg',
|
||||||
|
};
|
||||||
|
type UIStateSelection = typeof fakeState[keyof typeof fakeState];
|
||||||
|
const { mock } = await import('bun:test');
|
||||||
|
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
|
||||||
|
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||||
|
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => null }));
|
||||||
|
mock.module('@/stores/useUIStore', () => ({ useUIStore: Object.assign((selector: (state: typeof fakeState) => UIStateSelection) => selector(fakeState), { getState: () => fakeState }) }));
|
||||||
|
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
||||||
|
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
||||||
|
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
||||||
|
mock.module('@/lib/url', () => ({ isExternalHttpUrl: () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false }));
|
||||||
|
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
||||||
|
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
||||||
|
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
||||||
|
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '', normalizeFilePath: (value: string) => value, isAbsoluteFilePath: (value: string) => value.startsWith('/') }));
|
||||||
|
mock.module('@/lib/clipboard', () => ({ copyTextToClipboard: async () => undefined }));
|
||||||
|
mock.module('beautiful-mermaid', () => ({
|
||||||
|
renderMermaidASCII: () => 'diagram',
|
||||||
|
renderMermaidSVG: () => '<svg viewBox="0 0 240 120" width="240" height="120"><path d="M0 0h1v1z" /></svg>',
|
||||||
|
}));
|
||||||
|
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
|
||||||
|
mock.module('./markdown/markdown-worker', () => ({
|
||||||
|
highlightCodeInWorker: async () => null,
|
||||||
|
highlightLinesInWorker: async () => null,
|
||||||
|
highlightTokensInWorker: async () => null,
|
||||||
|
}));
|
||||||
|
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
|
||||||
|
const imported = await import('./MarkdownRendererImpl');
|
||||||
|
MarkdownRenderer = imported.MarkdownRenderer;
|
||||||
|
};
|
||||||
|
|
||||||
|
await initializePerformanceDom();
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
for (const [name, descriptor] of previousGlobals) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||||
|
else Reflect.deleteProperty(globalThis, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarkdownRenderer DOM mount performance contract', () => {
|
||||||
|
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
|
||||||
|
const mounted = await mountFixture(fixtureWorkload.rendererCount);
|
||||||
|
const critical = mounted.counts;
|
||||||
|
|
||||||
|
expect(critical.getBoundingClientRectCalls).toBe(0);
|
||||||
|
expect(critical.viewBoxWrites).toBe(0);
|
||||||
|
expect(critical.resizeObserverCreates).toBe(0);
|
||||||
|
expect(mounted.host.querySelectorAll('[data-markdown="mermaid"] svg')).toHaveLength(6);
|
||||||
|
|
||||||
|
await flushDeferredMermaidInitialization();
|
||||||
|
const metrics = {
|
||||||
|
...mounted.operations,
|
||||||
|
renderers: fixtureWorkload.rendererCount,
|
||||||
|
markdownBlocks: mounted.host.querySelectorAll('[data-md-block]').length,
|
||||||
|
mermaidBlocks: mounted.host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||||
|
mermaidRenderedCount: mounted.host.querySelectorAll('[data-mermaid-render]').length,
|
||||||
|
mermaidSvgCount: mounted.host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(metrics.renderers).toBe(3);
|
||||||
|
expect(metrics.markdownBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.domBlocksPerRenderer);
|
||||||
|
expect(metrics.mermaidBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.mermaidBlocksPerRenderer);
|
||||||
|
expect(metrics.mermaidRenderedCount).toBeGreaterThan(0);
|
||||||
|
expect(metrics.innerHTMLWrites).toBeGreaterThan(0);
|
||||||
|
expect(metrics.querySelectorAllCalls).toBeGreaterThan(0);
|
||||||
|
expect(metrics.appendCalls).toBeGreaterThan(0);
|
||||||
|
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount);
|
||||||
|
expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount);
|
||||||
|
expect(metrics.resizeObserverCreates).toBe(1);
|
||||||
|
expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount);
|
||||||
|
expect(metrics.geometrySequence.lastIndexOf('read')).toBeLessThan(metrics.geometrySequence.indexOf('write'));
|
||||||
|
|
||||||
|
const viewport = mounted.host.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]');
|
||||||
|
if (!viewport || !notifyResize) throw new Error('Expected initialized Mermaid viewport and shared observer');
|
||||||
|
const readsBeforeResize = mounted.operations.getBoundingClientRectCalls;
|
||||||
|
const writesBeforeResize = mounted.operations.viewBoxWrites;
|
||||||
|
notifyResize([{ target: viewport, contentRect: { width: 320, height: 180 } }]);
|
||||||
|
expect(mounted.operations.getBoundingClientRectCalls).toBe(readsBeforeResize);
|
||||||
|
expect(mounted.operations.viewBoxWrites).toBe(writesBeforeResize + 1);
|
||||||
|
console.log(JSON.stringify({ fixture: fixtureWorkload, baseline: metrics }));
|
||||||
|
await act(async () => mounted.root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancels deferred Mermaid initialization when the renderer unmounts first', async () => {
|
||||||
|
const mounted = await mountFixture(1);
|
||||||
|
await act(async () => mounted.root.unmount());
|
||||||
|
await flushDeferredMermaidInitialization();
|
||||||
|
|
||||||
|
expect(mounted.operations.getBoundingClientRectCalls).toBe(0);
|
||||||
|
expect(mounted.operations.viewBoxWrites).toBe(0);
|
||||||
|
expect(mounted.operations.resizeObserverCreates).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps DOM operation fanout linear when renderer count doubles', async () => {
|
||||||
|
const three = await runFixture(3);
|
||||||
|
const six = await runFixture(6);
|
||||||
|
|
||||||
|
expect(six.mermaidBlocks).toBe(three.mermaidBlocks * 2);
|
||||||
|
expect(six.mermaidRenderedCount).toBe(three.mermaidRenderedCount * 2);
|
||||||
|
expect(six.innerHTMLWrites).toBeLessThanOrEqual(three.innerHTMLWrites * 2 + 6);
|
||||||
|
expect(six.querySelectorAllCalls).toBeLessThanOrEqual(three.querySelectorAllCalls * 2 + 12);
|
||||||
|
expect(six.appendCalls).toBeLessThanOrEqual(three.appendCalls * 2 + 12);
|
||||||
|
expect(six.getBoundingClientRectCalls).toBe(three.getBoundingClientRectCalls * 2);
|
||||||
|
expect(six.viewBoxWrites).toBe(three.viewBoxWrites * 2);
|
||||||
|
expect(three.resizeObserverCreates).toBe(1);
|
||||||
|
expect(six.resizeObserverCreates).toBe(1);
|
||||||
|
expect(six.resizeObserverObserveCalls).toBe(three.resizeObserverObserveCalls * 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,18 @@ type MermaidViewerController = {
|
|||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type InternalMermaidViewerController = MermaidViewerController & {
|
||||||
|
viewport: HTMLElement;
|
||||||
|
fitToViewport: (viewport: MermaidViewport) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MermaidViewerRegistryState = {
|
||||||
|
container: HTMLElement;
|
||||||
|
controllers: Map<HTMLElement, InternalMermaidViewerController>;
|
||||||
|
signatures: Map<HTMLElement, string>;
|
||||||
|
disposed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type MermaidSvgBoundsSource = {
|
type MermaidSvgBoundsSource = {
|
||||||
viewBox?: string | null;
|
viewBox?: string | null;
|
||||||
width?: string | number | null;
|
width?: string | number | null;
|
||||||
@@ -36,13 +48,8 @@ type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
|
|||||||
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
|
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
|
||||||
|
|
||||||
const parseSvgNumber = (value: string | number | null | undefined): number | null => {
|
const parseSvgNumber = (value: string | number | null | undefined): number | null => {
|
||||||
if (typeof value === 'number') {
|
if (value === null || value === undefined) return null;
|
||||||
return isPositiveFinite(value) ? value : null;
|
const match = String(value).trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
|
||||||
}
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -233,10 +240,14 @@ export const zoomMermaidViewBoxAtPoint = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
|
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
|
||||||
|
const controllerByViewport = new WeakMap<HTMLElement, InternalMermaidViewerController>();
|
||||||
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => (
|
const activeControllers = new Set<InternalMermaidViewerController>();
|
||||||
block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null
|
const pendingRegistries = new Set<MermaidViewerRegistryState>();
|
||||||
);
|
// Controllers are non-essential for the static SVG. Initialize all renderers
|
||||||
|
// from one post-presentation batch so geometry reads precede every SVG write.
|
||||||
|
let sharedResizeObserver: ResizeObserver | null = null;
|
||||||
|
let pendingRegistryFlushFrame: number | null = null;
|
||||||
|
let pendingResizeFrame: number | null = null;
|
||||||
|
|
||||||
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
|
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
|
||||||
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
|
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
|
||||||
@@ -272,7 +283,62 @@ const isPanExcludedTarget = (target: EventTarget | null): boolean => (
|
|||||||
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
|
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
|
||||||
);
|
);
|
||||||
|
|
||||||
const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => {
|
const fitControllers = (controllers: readonly InternalMermaidViewerController[]): void => {
|
||||||
|
const viewportSizes = controllers.map((controller) => getViewportSize(controller.viewport));
|
||||||
|
controllers.forEach((controller, index) => {
|
||||||
|
const viewport = viewportSizes[index];
|
||||||
|
if (viewport) controller.fitToViewport(viewport);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleActiveControllerFit = (): void => {
|
||||||
|
if (pendingResizeFrame !== null || activeControllers.size === 0) return;
|
||||||
|
pendingResizeFrame = window.requestAnimationFrame(() => {
|
||||||
|
pendingResizeFrame = null;
|
||||||
|
fitControllers(Array.from(activeControllers));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureSharedResizeObserver = (): ResizeObserver | null => {
|
||||||
|
if (sharedResizeObserver) return sharedResizeObserver;
|
||||||
|
const ResizeObserverConstructor = globalThis.ResizeObserver;
|
||||||
|
if (!ResizeObserverConstructor) return null;
|
||||||
|
sharedResizeObserver = new ResizeObserverConstructor((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!(entry.target instanceof HTMLElement)) continue;
|
||||||
|
controllerByViewport.get(entry.target)?.fitToViewport({
|
||||||
|
width: entry.contentRect.width,
|
||||||
|
height: entry.contentRect.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sharedResizeObserver;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerController = (controller: InternalMermaidViewerController): void => {
|
||||||
|
if (activeControllers.has(controller)) return;
|
||||||
|
const wasEmpty = activeControllers.size === 0;
|
||||||
|
activeControllers.add(controller);
|
||||||
|
controllerByViewport.set(controller.viewport, controller);
|
||||||
|
ensureSharedResizeObserver()?.observe(controller.viewport);
|
||||||
|
if (wasEmpty) window.addEventListener('resize', scheduleActiveControllerFit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unregisterController = (controller: InternalMermaidViewerController): void => {
|
||||||
|
if (!activeControllers.delete(controller)) return;
|
||||||
|
sharedResizeObserver?.unobserve(controller.viewport);
|
||||||
|
controllerByViewport.delete(controller.viewport);
|
||||||
|
if (activeControllers.size > 0) return;
|
||||||
|
sharedResizeObserver?.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
window.removeEventListener('resize', scheduleActiveControllerFit);
|
||||||
|
if (pendingResizeFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(pendingResizeFrame);
|
||||||
|
pendingResizeFrame = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMermaidViewerController = (block: HTMLElement): InternalMermaidViewerController | null => {
|
||||||
const viewport = getSvgViewport(block);
|
const viewport = getSvgViewport(block);
|
||||||
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
|
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
|
||||||
if (!viewport || !svg) {
|
if (!viewport || !svg) {
|
||||||
@@ -301,8 +367,12 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
|||||||
svg.removeAttribute('height');
|
svg.removeAttribute('height');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fitToViewport = (size: MermaidViewport): void => {
|
||||||
|
applyViewBox(fitMermaidViewBox(contentBox, size));
|
||||||
|
};
|
||||||
|
|
||||||
const fit = (): void => {
|
const fit = (): void => {
|
||||||
applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport)));
|
fitToViewport(getViewportSize(viewport));
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
|
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
|
||||||
@@ -390,32 +460,24 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResize = (): void => {
|
|
||||||
fit();
|
|
||||||
};
|
|
||||||
|
|
||||||
viewport.addEventListener('wheel', onWheel, { passive: false });
|
viewport.addEventListener('wheel', onWheel, { passive: false });
|
||||||
viewport.addEventListener('pointerdown', onPointerDown);
|
viewport.addEventListener('pointerdown', onPointerDown);
|
||||||
viewport.addEventListener('pointermove', onPointerMove);
|
viewport.addEventListener('pointermove', onPointerMove);
|
||||||
viewport.addEventListener('pointerup', stopPan);
|
viewport.addEventListener('pointerup', stopPan);
|
||||||
viewport.addEventListener('pointercancel', stopPan);
|
viewport.addEventListener('pointercancel', stopPan);
|
||||||
window.addEventListener('resize', onResize);
|
const controller: InternalMermaidViewerController = {
|
||||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
|
viewport,
|
||||||
observer?.observe(viewport);
|
|
||||||
fit();
|
|
||||||
|
|
||||||
return {
|
|
||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut,
|
zoomOut,
|
||||||
fit,
|
fit,
|
||||||
|
fitToViewport,
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
|
unregisterController(controller);
|
||||||
viewport.removeEventListener('wheel', onWheel);
|
viewport.removeEventListener('wheel', onWheel);
|
||||||
viewport.removeEventListener('pointerdown', onPointerDown);
|
viewport.removeEventListener('pointerdown', onPointerDown);
|
||||||
viewport.removeEventListener('pointermove', onPointerMove);
|
viewport.removeEventListener('pointermove', onPointerMove);
|
||||||
viewport.removeEventListener('pointerup', stopPan);
|
viewport.removeEventListener('pointerup', stopPan);
|
||||||
viewport.removeEventListener('pointercancel', stopPan);
|
viewport.removeEventListener('pointercancel', stopPan);
|
||||||
window.removeEventListener('resize', onResize);
|
|
||||||
observer?.disconnect();
|
|
||||||
if (clearClickSuppressionTimer !== null) {
|
if (clearClickSuppressionTimer !== null) {
|
||||||
window.clearTimeout(clearClickSuppressionTimer);
|
window.clearTimeout(clearClickSuppressionTimer);
|
||||||
}
|
}
|
||||||
@@ -424,42 +486,97 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
|||||||
controllerByBlock.delete(block);
|
controllerByBlock.delete(block);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return controller;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => {
|
const removeStaleControllers = (state: MermaidViewerRegistryState): void => {
|
||||||
const controllers = new Map<HTMLElement, MermaidViewerController>();
|
for (const [block, controller] of state.controllers) {
|
||||||
const signatures = new Map<HTMLElement, string>();
|
const signature = getBlockViewerSignature(block);
|
||||||
|
if (!state.container.contains(block) || signature !== state.signatures.get(block)) {
|
||||||
const refresh = (): void => {
|
controller.cleanup();
|
||||||
for (const [block, controller] of Array.from(controllers.entries())) {
|
state.controllers.delete(block);
|
||||||
const signature = getBlockViewerSignature(block);
|
state.signatures.delete(block);
|
||||||
if (!container.contains(block) || signature !== signatures.get(block)) {
|
|
||||||
controller.cleanup();
|
|
||||||
controllers.delete(block);
|
|
||||||
signatures.delete(block);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (const block of Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
|
const collectNewControllers = (state: MermaidViewerRegistryState): InternalMermaidViewerController[] => {
|
||||||
if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) {
|
if (state.disposed) return [];
|
||||||
continue;
|
const newControllers: InternalMermaidViewerController[] = [];
|
||||||
}
|
for (const block of Array.from(state.container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
|
||||||
const controller = createMermaidViewerController(block);
|
if (state.controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) continue;
|
||||||
if (!controller) {
|
const controller = createMermaidViewerController(block);
|
||||||
continue;
|
if (!controller) continue;
|
||||||
}
|
state.controllers.set(block, controller);
|
||||||
controllers.set(block, controller);
|
state.signatures.set(block, getBlockViewerSignature(block));
|
||||||
signatures.set(block, getBlockViewerSignature(block));
|
controllerByBlock.set(block, controller);
|
||||||
controllerByBlock.set(block, controller);
|
newControllers.push(controller);
|
||||||
}
|
}
|
||||||
|
return newControllers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushPendingRegistries = (): void => {
|
||||||
|
const registries = Array.from(pendingRegistries);
|
||||||
|
pendingRegistries.clear();
|
||||||
|
const newControllers: InternalMermaidViewerController[] = [];
|
||||||
|
for (const state of registries) {
|
||||||
|
if (state.disposed) continue;
|
||||||
|
removeStaleControllers(state);
|
||||||
|
newControllers.push(...collectNewControllers(state));
|
||||||
|
}
|
||||||
|
fitControllers(newControllers);
|
||||||
|
for (const controller of newControllers) registerController(controller);
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulePendingRegistryFlush = (): void => {
|
||||||
|
if (pendingRegistryFlushFrame !== null) return;
|
||||||
|
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
|
||||||
|
pendingRegistryFlushFrame = null;
|
||||||
|
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
|
||||||
|
pendingRegistryFlushFrame = null;
|
||||||
|
flushPendingRegistries();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRegistryRefresh = (state: MermaidViewerRegistryState): void => {
|
||||||
|
if (state.disposed) return;
|
||||||
|
removeStaleControllers(state);
|
||||||
|
pendingRegistries.add(state);
|
||||||
|
schedulePendingRegistryFlush();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => {
|
||||||
|
if (!(block instanceof HTMLElement)) return null;
|
||||||
|
const existing = controllerByBlock.get(block);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
for (const state of pendingRegistries) {
|
||||||
|
if (!state.container.contains(block)) continue;
|
||||||
|
flushPendingRegistries();
|
||||||
|
return controllerByBlock.get(block) ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMermaidViewerRegistry = (container: HTMLElement) => {
|
||||||
|
const state: MermaidViewerRegistryState = {
|
||||||
|
container,
|
||||||
|
controllers: new Map(),
|
||||||
|
signatures: new Map(),
|
||||||
|
disposed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refresh = (): void => scheduleRegistryRefresh(state);
|
||||||
|
|
||||||
const cleanup = (): void => {
|
const cleanup = (): void => {
|
||||||
for (const controller of controllers.values()) {
|
state.disposed = true;
|
||||||
|
pendingRegistries.delete(state);
|
||||||
|
for (const controller of state.controllers.values()) {
|
||||||
controller.cleanup();
|
controller.cleanup();
|
||||||
}
|
}
|
||||||
controllers.clear();
|
state.controllers.clear();
|
||||||
signatures.clear();
|
state.signatures.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
Reference in New Issue
Block a user