mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
fix: Bound plain text cache in command bar search index (#13252)
* fix: Bound plain text cache in command bar search index Adds a shared LRUCache utility with optional sessionStorage persistence and uses it for the command bar plain text cache, which previously grew without limit, and for the Mermaid diagram cache, which had its own bespoke LRU implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: Avoid double lookup in LRUCache.get Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: Read persisted LRUCache values lazily Hydration now loads only the key index, reading each value from storage on first access. This avoids a synchronous read and parse of the entire cache on first use, and keeps in memory only the entries the session actually touches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: Assert lazy LRUCache reads without a storage spy Spying on Storage.prototype missed the calls under Node 26, where a native Storage global shadows the jsdom one. Replacing the stored value after hydration asserts the same behavior independently of environment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
257c2bfdc0
commit
4bb422425c
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type Document from "~/models/Document";
|
||||
import { LRUCache } from "@shared/utils/LRUCache";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
import {
|
||||
SearchIndex,
|
||||
@@ -7,12 +8,15 @@ import {
|
||||
type SearchIndexResult,
|
||||
} from "./SearchIndex";
|
||||
|
||||
const plainTextCache = new Map<string, { updatedAt: string; text: string }>();
|
||||
const plainTextCache = new LRUCache<{ updatedAt: string; text: string }>({
|
||||
max: 100,
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the plain text content of a document, memoized so that repeated
|
||||
* indexing passes do not reparse unchanged ProseMirror data. The cache holds at
|
||||
* most one entry per document, invalidated when the document is edited.
|
||||
* most one entry per document, invalidated when the document is edited, and is
|
||||
* bounded to the most recently used documents.
|
||||
*/
|
||||
function getPlainText(doc: Document): string {
|
||||
const updatedAt = String(doc.updatedAt ?? "");
|
||||
@@ -71,7 +75,7 @@ export interface UseSearchIndex {
|
||||
results: SearchIndexResult[];
|
||||
/** Merges documents into the index, re-running the search if anything changed. */
|
||||
feed: (documents: SearchIndexDocument[]) => void;
|
||||
/** Empties the index. */
|
||||
/** Empties the index and any cached document content. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -104,6 +108,7 @@ export function useSearchIndex(query: string): UseSearchIndex {
|
||||
|
||||
const reset = useCallback(() => {
|
||||
index.clear();
|
||||
plainTextCache.clear();
|
||||
setVersion((v) => v + 1);
|
||||
}, [index]);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { NodeWithPos } from "../types";
|
||||
import type { Editor } from "../../../app/editor";
|
||||
import { LightboxImageFactory } from "../lib/Lightbox";
|
||||
import { hashString } from "../../utils/string";
|
||||
import { LRUCache } from "../../utils/LRUCache";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
import { isModKey } from "../../utils/keyboard";
|
||||
|
||||
@@ -29,73 +30,11 @@ export type MermaidState = {
|
||||
|
||||
// The `v3` namespace discards entries cached before the foreignObject fix, so
|
||||
// previously mis-sized diagrams are re-rendered instead of served from cache.
|
||||
const STORAGE_PREFIX = "mermaid:v3:";
|
||||
const MAX_STORAGE_ENTRIES = 20;
|
||||
|
||||
class Cache {
|
||||
/** Get a cached SVG by diagram text and theme. */
|
||||
static get(key: string): string | undefined {
|
||||
try {
|
||||
const hash = hashString(key);
|
||||
const value = sessionStorage.getItem(STORAGE_PREFIX + hash);
|
||||
if (value) {
|
||||
this.touchLru(hash);
|
||||
return value;
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage unavailable
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Cache a rendered SVG in sessionStorage. */
|
||||
static set(key: string, value: string) {
|
||||
try {
|
||||
const hash = hashString(key);
|
||||
this.touchLru(hash);
|
||||
this.pruneStorage();
|
||||
sessionStorage.setItem(STORAGE_PREFIX + hash, value);
|
||||
} catch {
|
||||
// sessionStorage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/** Move or append a hash to the end (most recent) of the LRU list. */
|
||||
private static touchLru(hash: string) {
|
||||
const lru = this.getLru();
|
||||
const idx = lru.indexOf(hash);
|
||||
if (idx !== -1) {
|
||||
lru.splice(idx, 1);
|
||||
}
|
||||
lru.push(hash);
|
||||
sessionStorage.setItem(STORAGE_PREFIX + "lru", JSON.stringify(lru));
|
||||
}
|
||||
|
||||
/** Evict least-recently-used entries when over the limit. */
|
||||
private static pruneStorage() {
|
||||
const lru = this.getLru();
|
||||
|
||||
while (lru.length > MAX_STORAGE_ENTRIES) {
|
||||
const evict = lru.shift()!;
|
||||
sessionStorage.removeItem(STORAGE_PREFIX + evict);
|
||||
}
|
||||
|
||||
sessionStorage.setItem(STORAGE_PREFIX + "lru", JSON.stringify(lru));
|
||||
}
|
||||
|
||||
/** Read the LRU order list from sessionStorage. */
|
||||
private static getLru(): string[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_PREFIX + "lru");
|
||||
if (raw) {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
} catch {
|
||||
// corrupted or unavailable
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const cache = new LRUCache<string>({
|
||||
max: 20,
|
||||
namespace: "mermaid:v3",
|
||||
persistToSession: true,
|
||||
});
|
||||
|
||||
let mermaid: typeof MermaidUnsafe;
|
||||
|
||||
@@ -151,11 +90,11 @@ class MermaidRenderer {
|
||||
const element = this.element;
|
||||
const text = block.node.textContent;
|
||||
|
||||
const cacheKey = `${isDark ? "dark" : "light"}-${text}`;
|
||||
const cache = Cache.get(cacheKey);
|
||||
if (cache) {
|
||||
const cacheKey = hashString(`${isDark ? "dark" : "light"}-${text}`);
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
element.classList.remove("parse-error", "empty");
|
||||
element.innerHTML = cache;
|
||||
element.innerHTML = cached;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -260,7 +199,7 @@ class MermaidRenderer {
|
||||
|
||||
// Cache the corrected SVG so we won't need to calculate it again this session
|
||||
if (text) {
|
||||
Cache.set(cacheKey, element.innerHTML);
|
||||
cache.set(cacheKey, element.innerHTML);
|
||||
}
|
||||
} catch (error) {
|
||||
const isEmpty = block.node.textContent.trim().length === 0;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { LRUCache } from "./LRUCache";
|
||||
|
||||
describe("LRUCache", () => {
|
||||
it("stores and retrieves values", () => {
|
||||
const cache = new LRUCache<number>({ max: 3 });
|
||||
cache.set("a", 1);
|
||||
|
||||
expect(cache.get("a")).toBe(1);
|
||||
expect(cache.get("missing")).toBeUndefined();
|
||||
expect(cache.size).toBe(1);
|
||||
});
|
||||
|
||||
it("evicts the least recently used entry when over max", () => {
|
||||
const cache = new LRUCache<number>({ max: 2 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.set("c", 3);
|
||||
|
||||
expect(cache.size).toBe(2);
|
||||
expect(cache.get("a")).toBeUndefined();
|
||||
expect(cache.get("b")).toBe(2);
|
||||
expect(cache.get("c")).toBe(3);
|
||||
});
|
||||
|
||||
it("marks an entry as recently used when read", () => {
|
||||
const cache = new LRUCache<number>({ max: 2 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.get("a");
|
||||
cache.set("c", 3);
|
||||
|
||||
expect(cache.get("a")).toBe(1);
|
||||
expect(cache.get("b")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("overwrites without growing", () => {
|
||||
const cache = new LRUCache<number>({ max: 2 });
|
||||
cache.set("a", 1);
|
||||
cache.set("a", 2);
|
||||
|
||||
expect(cache.size).toBe(1);
|
||||
expect(cache.get("a")).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes and clears entries", () => {
|
||||
const cache = new LRUCache<number>({ max: 3 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
|
||||
expect(cache.delete("a")).toBe(true);
|
||||
expect(cache.delete("a")).toBe(false);
|
||||
expect(cache.has("a")).toBe(false);
|
||||
expect(cache.has("b")).toBe(true);
|
||||
|
||||
cache.clear();
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
// Shared tests run in both node and jsdom; web storage only exists in jsdom.
|
||||
const hasStorage = typeof window !== "undefined";
|
||||
|
||||
describe.runIf(!hasStorage)("without web storage", () => {
|
||||
it("falls back to an in-memory cache", () => {
|
||||
const cache = new LRUCache<string>({
|
||||
max: 1,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
cache.set("a", "one");
|
||||
cache.set("b", "two");
|
||||
|
||||
expect(cache.get("a")).toBeUndefined();
|
||||
expect(cache.get("b")).toBe("two");
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(hasStorage)("with persistence", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("restores entries from storage", () => {
|
||||
const cache = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
cache.set("a", "one");
|
||||
cache.set("b", "two");
|
||||
|
||||
const restored = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
expect(restored.get("a")).toBe("one");
|
||||
expect(restored.get("b")).toBe("two");
|
||||
});
|
||||
|
||||
it("reads persisted values only when they are accessed", () => {
|
||||
const cache = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
cache.set("a", "one");
|
||||
cache.set("b", "two");
|
||||
|
||||
const restored = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
// Hydrating reads the index alone, so a value replaced afterwards is
|
||||
// still the one returned on first access.
|
||||
expect(restored.size).toBe(2);
|
||||
sessionStorage.setItem("test:a", JSON.stringify("updated"));
|
||||
expect(restored.get("a")).toBe("updated");
|
||||
|
||||
// Once in memory the value is not read from storage again.
|
||||
sessionStorage.setItem("test:a", JSON.stringify("ignored"));
|
||||
expect(restored.get("a")).toBe("updated");
|
||||
});
|
||||
|
||||
it("drops an indexed key whose value is missing", () => {
|
||||
const cache = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
cache.set("a", "one");
|
||||
sessionStorage.removeItem("test:a");
|
||||
|
||||
const restored = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
expect(restored.get("a")).toBeUndefined();
|
||||
expect(restored.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not restore entries from another namespace", () => {
|
||||
const cache = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
cache.set("a", "one");
|
||||
|
||||
const other = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "other",
|
||||
persistToSession: true,
|
||||
});
|
||||
expect(other.get("a")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes evicted entries from storage", () => {
|
||||
const cache = new LRUCache<string>({
|
||||
max: 1,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
cache.set("a", "one");
|
||||
cache.set("b", "two");
|
||||
|
||||
expect(sessionStorage.getItem("test:a")).toBeNull();
|
||||
|
||||
const restored = new LRUCache<string>({
|
||||
max: 1,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
expect(restored.get("a")).toBeUndefined();
|
||||
expect(restored.get("b")).toBe("two");
|
||||
});
|
||||
|
||||
it("removes persisted entries on clear", () => {
|
||||
const cache = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
cache.set("a", "one");
|
||||
cache.clear();
|
||||
|
||||
const restored = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
expect(restored.size).toBe(0);
|
||||
});
|
||||
|
||||
it("starts empty when the persisted index is corrupt", () => {
|
||||
sessionStorage.setItem("test:keys", "not json");
|
||||
|
||||
const cache = new LRUCache<string>({
|
||||
max: 3,
|
||||
namespace: "test",
|
||||
persistToSession: true,
|
||||
});
|
||||
expect(cache.size).toBe(0);
|
||||
|
||||
cache.set("a", "one");
|
||||
expect(cache.get("a")).toBe("one");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Storage } from "./Storage";
|
||||
|
||||
/** Options accepted when constructing an `LRUCache`. */
|
||||
export interface LRUCacheOptions {
|
||||
/** The maximum number of entries to retain before evicting. */
|
||||
max: number;
|
||||
/** Prefix under which entries are stored, used to namespace and version them. */
|
||||
namespace?: string;
|
||||
/** Mirror entries to sessionStorage, requires a namespace. */
|
||||
persistToSession?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cache holding a bounded number of entries, evicting the least recently used
|
||||
* once full. Reading or writing an entry marks it as the most recently used.
|
||||
*
|
||||
* Entries are optionally mirrored to sessionStorage so that they survive a
|
||||
* reload, in which case values must be JSON-serializable. Persisted values are
|
||||
* read back only when they are first accessed, so the cache holds no more in
|
||||
* memory than the session has used. Storage failures are ignored, leaving the
|
||||
* cache to operate in memory alone.
|
||||
*/
|
||||
export class LRUCache<T> {
|
||||
public constructor(options: LRUCacheOptions) {
|
||||
this.max = options.max;
|
||||
this.namespace = options.namespace;
|
||||
this.persistToSession = options.persistToSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value stored under a key, marking it as most recently used.
|
||||
*
|
||||
* @param key the key to look up.
|
||||
* @returns the cached value, or undefined if it is not cached.
|
||||
*/
|
||||
public get(key: string): T | undefined {
|
||||
this.hydrate();
|
||||
|
||||
// `has` distinguishes a missing key from one that is persisted but has not
|
||||
// been read into memory yet.
|
||||
const value = this.data.has(key)
|
||||
? (this.data.get(key) ?? this.load(key))
|
||||
: undefined;
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Re-insert so that the entry becomes the most recently used.
|
||||
this.data.delete(key);
|
||||
this.data.set(key, value);
|
||||
this.writeKeys();
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a value under a key, evicting the least recently used entries when
|
||||
* the cache is over its maximum size.
|
||||
*
|
||||
* @param key the key to store under.
|
||||
* @param value the value to store.
|
||||
*/
|
||||
public set(key: string, value: T) {
|
||||
this.hydrate();
|
||||
|
||||
this.data.delete(key);
|
||||
this.data.set(key, value);
|
||||
this.writeValue(key, value);
|
||||
|
||||
// Map iteration follows insertion order, so the first key is the least
|
||||
// recently used.
|
||||
while (this.data.size > this.max) {
|
||||
const oldest = this.data.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
this.data.delete(oldest);
|
||||
this.removeValue(oldest);
|
||||
}
|
||||
|
||||
this.writeKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a key is cached, without affecting its recency.
|
||||
*
|
||||
* @param key the key to look up.
|
||||
* @returns true if the key is cached.
|
||||
*/
|
||||
public has(key: string): boolean {
|
||||
this.hydrate();
|
||||
return this.data.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the entry stored under a key.
|
||||
*
|
||||
* @param key the key to remove.
|
||||
* @returns true if an entry was removed.
|
||||
*/
|
||||
public delete(key: string): boolean {
|
||||
this.hydrate();
|
||||
|
||||
if (!this.data.delete(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeValue(key);
|
||||
this.writeKeys();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Removes every entry from the cache, including any persisted copies. */
|
||||
public clear() {
|
||||
this.hydrate();
|
||||
|
||||
for (const key of this.data.keys()) {
|
||||
this.removeValue(key);
|
||||
}
|
||||
this.data.clear();
|
||||
this.writeKeys();
|
||||
}
|
||||
|
||||
/** The number of entries currently cached. */
|
||||
public get size(): number {
|
||||
this.hydrate();
|
||||
return this.data.size;
|
||||
}
|
||||
|
||||
private max: number;
|
||||
private namespace?: string;
|
||||
private persistToSession?: boolean;
|
||||
// An entry is undefined while it is persisted but not yet read into memory.
|
||||
private data = new Map<string, T | undefined>();
|
||||
private storage?: Storage;
|
||||
private hydrated = false;
|
||||
|
||||
/** The storage key holding the cached keys in least-to-most recent order. */
|
||||
private get keysKey(): string {
|
||||
return `${this.namespace}:keys`;
|
||||
}
|
||||
|
||||
private valueKey(key: string): string {
|
||||
return `${this.namespace}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads persisted entries into memory, deferred until first use so that
|
||||
* constructing a cache at module scope does not read from storage on import.
|
||||
*/
|
||||
private hydrate() {
|
||||
if (this.hydrated) {
|
||||
return;
|
||||
}
|
||||
this.hydrated = true;
|
||||
|
||||
if (!this.persistToSession || !this.namespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.storage = new Storage("session");
|
||||
|
||||
// A corrupt or absent index reads as undefined, leaving the cache empty.
|
||||
const keys: unknown = this.storage.get(this.keysKey);
|
||||
if (!Array.isArray(keys)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of keys.slice(-this.max)) {
|
||||
if (typeof key === "string") {
|
||||
this.data.set(key, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a persisted value into memory, dropping the key if its value is no
|
||||
* longer stored, which happens when an earlier write exceeded the quota.
|
||||
*/
|
||||
private load(key: string): T | undefined {
|
||||
const value: T | undefined = this.storage?.get(this.valueKey(key));
|
||||
if (value === undefined) {
|
||||
this.data.delete(key);
|
||||
this.writeKeys();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.data.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private writeValue(key: string, value: T) {
|
||||
this.storage?.set(this.valueKey(key), value);
|
||||
}
|
||||
|
||||
private removeValue(key: string) {
|
||||
this.storage?.remove(this.valueKey(key));
|
||||
}
|
||||
|
||||
private writeKeys() {
|
||||
this.storage?.set(this.keysKey, [...this.data.keys()]);
|
||||
}
|
||||
}
|
||||
+14
-10
@@ -1,22 +1,26 @@
|
||||
import type { Primitive } from "utility-types";
|
||||
|
||||
/**
|
||||
* Storage is a wrapper class for localStorage that allow safe usage when
|
||||
* localStorage is not available.
|
||||
* Storage is a wrapper class for web storage that allows safe usage when
|
||||
* localStorage or sessionStorage are not available.
|
||||
*/
|
||||
class Storage {
|
||||
export class Storage {
|
||||
interface: typeof localStorage | MemoryStorage;
|
||||
|
||||
public constructor() {
|
||||
/**
|
||||
* @param type whether to persist for the session only, or indefinitely.
|
||||
*/
|
||||
public constructor(type: "local" | "session" = "local") {
|
||||
try {
|
||||
// Avoid touching the `localStorage` global outside the browser; in Node it
|
||||
// resolves to an experimental Web Storage API that emits a warning on access.
|
||||
// Avoid touching the storage globals outside the browser; in Node they
|
||||
// resolve to an experimental Web Storage API that emits a warning on access.
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("localStorage is not available");
|
||||
throw new Error("Web storage is not available");
|
||||
}
|
||||
localStorage.setItem("test", "test");
|
||||
localStorage.removeItem("test");
|
||||
this.interface = localStorage;
|
||||
const storage = type === "session" ? sessionStorage : localStorage;
|
||||
storage.setItem("test", "test");
|
||||
storage.removeItem("test");
|
||||
this.interface = storage;
|
||||
} catch (_err) {
|
||||
this.interface = new MemoryStorage();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user