perf(ui): reuse detached markdown DOM
This commit is contained in:
@@ -554,7 +554,12 @@ export const attachMarkdownInteractions = (
|
||||
if (action === 'copy-code') {
|
||||
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
|
||||
const text = code ? getMarkdownCodeText(code) : '';
|
||||
if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy));
|
||||
if (text) {
|
||||
actionEl.setAttribute('data-md-copy-pending', '');
|
||||
void copyTextToClipboard(text)
|
||||
.then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy))
|
||||
.finally(() => actionEl.removeAttribute('data-md-copy-pending'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMarkdownDomCache';
|
||||
|
||||
Object.assign(globalThis, { document: new Window().document });
|
||||
|
||||
const keyFor = ({ scope, id, locale }: DetachedMarkdownDom) => ({ scope, id, locale });
|
||||
|
||||
const createEntry = (
|
||||
document: Document,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
): DetachedMarkdownDom => {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const node = document.createElement('p');
|
||||
node.textContent = `${messageId}:${partId}`;
|
||||
fragment.appendChild(node);
|
||||
return {
|
||||
scope: `runtime:${sessionId}`,
|
||||
id: `${messageId}:${partId}`,
|
||||
locale: 'en',
|
||||
fragment,
|
||||
};
|
||||
};
|
||||
|
||||
describe('DetachedMarkdownDomCache', () => {
|
||||
test('consumes the original DOM fragment once and rejects another locale', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const entry = createEntry(document, 'session-a', 'message-a', 'part-a');
|
||||
const originalNode = entry.fragment.firstChild;
|
||||
|
||||
cache.store(entry);
|
||||
expect(cache.take({ ...keyFor(entry), locale: 'zh' })).toBeNull();
|
||||
cache.store(entry);
|
||||
const restored = cache.take(keyFor(entry));
|
||||
expect(restored?.firstChild).toBe(originalNode);
|
||||
expect(cache.take(keyFor(entry))).toBeNull();
|
||||
});
|
||||
|
||||
test('bounds entries per session and evicts the least recently used session', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
cache.store(createEntry(document, 'session-a', 'message-1', 'part'));
|
||||
cache.store(createEntry(document, 'session-a', 'message-2', 'part'));
|
||||
cache.store(createEntry(document, 'session-a', 'message-3', 'part'));
|
||||
cache.store(createEntry(document, 'session-b', 'message-4', 'part'));
|
||||
cache.store(createEntry(document, 'session-c', 'message-5', 'part'));
|
||||
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-a',
|
||||
id: 'message-2:part',
|
||||
locale: 'en',
|
||||
})).toBeNull();
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-c',
|
||||
id: 'message-5:part',
|
||||
locale: 'en',
|
||||
})).not.toBeNull();
|
||||
});
|
||||
|
||||
test('isolates identities by runtime and replaces an identity without growing stats', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const first = createEntry(document, 'session', 'message', 'part');
|
||||
const replacement = createEntry(document, 'session', 'message', 'part');
|
||||
const replacementNode = replacement.fragment.firstChild;
|
||||
const otherRuntime = createEntry(document, 'other-runtime-session', 'message', 'part');
|
||||
|
||||
cache.store(first);
|
||||
cache.store(replacement);
|
||||
cache.store(otherRuntime);
|
||||
|
||||
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
|
||||
expect(cache.take(keyFor(otherRuntime))).not.toBeNull();
|
||||
expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
|
||||
});
|
||||
|
||||
test('refreshes session LRU and clears all entries', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
|
||||
const sessionB = createEntry(document, 'session-b', 'message-b', 'part');
|
||||
const sessionC = createEntry(document, 'session-c', 'message-c', 'part');
|
||||
cache.store(sessionA);
|
||||
cache.store(sessionB);
|
||||
cache.store(sessionA);
|
||||
cache.store(sessionC);
|
||||
expect(cache.take(keyFor(sessionB))).toBeNull();
|
||||
|
||||
cache.clear();
|
||||
expect(cache.stats()).toEqual({ sessions: 0, entries: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
export type DetachedMarkdownDomKey = {
|
||||
scope: string;
|
||||
id: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
|
||||
// The fragment owns the original nodes. take() consumes it once by moving
|
||||
// those nodes back into a renderer; nothing is cloned or serialized.
|
||||
fragment: DocumentFragment;
|
||||
};
|
||||
|
||||
export type DetachedMarkdownDomCacheStats = {
|
||||
sessions: number;
|
||||
entries: number;
|
||||
};
|
||||
|
||||
// Holds detached, fully decorated Markdown DOM. The cache is intentionally
|
||||
// small: it accelerates recent-session and reverse-scroll remounts without
|
||||
// retaining whole session trees or depending on browser-specific byte guesses.
|
||||
type DetachedMarkdownDomCacheLimits = {
|
||||
maxSessions: number;
|
||||
maxEntriesPerSession: number;
|
||||
};
|
||||
|
||||
type SessionCache = Map<string, DetachedMarkdownDom>;
|
||||
|
||||
const DEFAULT_LIMITS: DetachedMarkdownDomCacheLimits = {
|
||||
// Three buckets cover the common A/B/C recent-session rotation without
|
||||
// coupling eviction to React commit or microtask timing.
|
||||
maxSessions: 3,
|
||||
maxEntriesPerSession: 12,
|
||||
};
|
||||
|
||||
export class DetachedMarkdownDomCache {
|
||||
private readonly maxSessions: number;
|
||||
private readonly maxEntriesPerSession: number;
|
||||
private readonly sessions = new Map<string, SessionCache>();
|
||||
|
||||
constructor(limits: DetachedMarkdownDomCacheLimits = DEFAULT_LIMITS) {
|
||||
this.maxSessions = Math.max(1, limits.maxSessions);
|
||||
this.maxEntriesPerSession = Math.max(1, limits.maxEntriesPerSession);
|
||||
}
|
||||
|
||||
store(entry: DetachedMarkdownDom): void {
|
||||
const sessionKey = entry.scope;
|
||||
const entryKey = entry.id;
|
||||
|
||||
let session = this.sessions.get(sessionKey);
|
||||
if (session === undefined) {
|
||||
session = new Map();
|
||||
this.sessions.set(sessionKey, session);
|
||||
} else {
|
||||
this.refreshSession(sessionKey, session);
|
||||
}
|
||||
|
||||
// A part has one DOM version inside its authoritative runtime/session.
|
||||
session.delete(entryKey);
|
||||
session.set(entryKey, entry);
|
||||
|
||||
while (session.size > this.maxEntriesPerSession) {
|
||||
this.removeOldestEntry(session);
|
||||
}
|
||||
while (this.sessions.size > this.maxSessions) {
|
||||
this.removeOldestSession();
|
||||
}
|
||||
}
|
||||
|
||||
take(key: DetachedMarkdownDomKey): DocumentFragment | null {
|
||||
const sessionKey = key.scope;
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return null;
|
||||
const entryKey = key.id;
|
||||
|
||||
this.refreshSession(sessionKey, session);
|
||||
const entry = session.get(entryKey);
|
||||
if (entry === undefined) return null;
|
||||
|
||||
// A fragment is a move-only resource; taking it removes cache ownership.
|
||||
session.delete(entryKey);
|
||||
if (session.size === 0) this.sessions.delete(sessionKey);
|
||||
if (entry.locale !== key.locale) return null;
|
||||
return entry.fragment;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
stats(): DetachedMarkdownDomCacheStats {
|
||||
let entries = 0;
|
||||
for (const session of this.sessions.values()) {
|
||||
entries += session.size;
|
||||
}
|
||||
return {
|
||||
sessions: this.sessions.size,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
private refreshSession(sessionKey: string, session: SessionCache): void {
|
||||
this.sessions.delete(sessionKey);
|
||||
this.sessions.set(sessionKey, session);
|
||||
}
|
||||
|
||||
private removeOldestEntry(session: SessionCache): void {
|
||||
const oldestKey = session.keys().next().value;
|
||||
if (oldestKey === undefined) return;
|
||||
session.delete(oldestKey);
|
||||
}
|
||||
|
||||
private removeOldestSession(): void {
|
||||
const oldestKey = this.sessions.keys().next().value;
|
||||
if (oldestKey === undefined) return;
|
||||
this.sessions.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const detachedMarkdownDomCache = new DetachedMarkdownDomCache();
|
||||
Reference in New Issue
Block a user