perf(web): fast rules search with memoized sections and highlighting

Memoize Markdown rendering (React.memo) so sections parse once on mount.
Search filters by toggling visibility (no re-render), with debounced
input and DOM-based text highlighting for matches in both titles and
content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bas van Rossem
2026-02-19 17:48:45 +01:00
parent 69e53ed695
commit 19666efd5b
2 changed files with 136 additions and 26 deletions

View File

@@ -671,3 +671,11 @@ details[open] > .rules-summary::before {
.rules-content p { .rules-content p {
margin: var(--spacing-xs) 0; margin: var(--spacing-xs) 0;
} }
mark.search-hl {
background: rgba(255, 170, 0, 0.35);
color: inherit;
border-radius: 2px;
padding: 0 1px;
}

View File

@@ -1,22 +1,124 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect, useRef, memo } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import TopBar from '../components/layout/TopBar'; import TopBar from '../components/layout/TopBar';
import PageContainer from '../components/layout/PageContainer'; import PageContainer from '../components/layout/PageContainer';
import { battleReferenceSections } from '../rules/battle-reference'; import { battleReferenceSections } from '../rules/battle-reference';
const plugins = [remarkGfm];
const mdComponents = {
table: ({ children }: { children?: React.ReactNode }) => (
<div className="table-wrap"><table>{children}</table></div>
),
};
// Memoised so Markdown only renders once per section, never re-parses on search
const SectionContent = memo(function SectionContent({ content }: { content: string }) {
return (
<div className="rules-content">
<Markdown remarkPlugins={plugins} components={mdComponents}>
{content}
</Markdown>
</div>
);
});
function HighlightText({ text, query }: { text: string; query: string }) {
if (!query) return <>{text}</>;
const lower = text.toLowerCase();
const q = query.toLowerCase();
const parts: React.ReactNode[] = [];
let lastIdx = 0;
let idx = lower.indexOf(q);
let key = 0;
while (idx !== -1) {
if (idx > lastIdx) parts.push(text.slice(lastIdx, idx));
parts.push(<mark key={key++} className="search-hl">{text.slice(idx, idx + q.length)}</mark>);
lastIdx = idx + q.length;
idx = lower.indexOf(q, lastIdx);
}
if (lastIdx < text.length) parts.push(text.slice(lastIdx));
return <>{parts}</>;
}
export default function RulesPage() { export default function RulesPage() {
const [openSections, setOpenSections] = useState<Set<number>>(new Set()); const [openSections, setOpenSections] = useState<Set<number>>(new Set());
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const sectionsRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => { useEffect(() => {
if (!search.trim()) return battleReferenceSections.map((s, i) => ({ ...s, originalIndex: i })); const timer = setTimeout(() => setDebouncedSearch(search), 250);
const q = search.toLowerCase(); return () => clearTimeout(timer);
return battleReferenceSections
.map((s, i) => ({ ...s, originalIndex: i }))
.filter((s) => s.title.toLowerCase().includes(q) || s.content.toLowerCase().includes(q));
}, [search]); }, [search]);
// Which sections match (null = show all)
const matchSet = useMemo(() => {
const q = debouncedSearch.trim().toLowerCase();
if (!q) return null;
const set = new Set<number>();
battleReferenceSections.forEach((s, i) => {
if (s.title.toLowerCase().includes(q) || s.content.toLowerCase().includes(q))
set.add(i);
});
return set;
}, [debouncedSearch]);
// Auto-expand matching sections when searching
useEffect(() => {
if (matchSet) setOpenSections(new Set(matchSet));
}, [matchSet]);
// Highlight matching text inside rendered markdown (DOM-based, no React re-render)
useEffect(() => {
const container = sectionsRef.current;
if (!container) return;
// Clear old highlights
container.querySelectorAll('mark.search-hl').forEach((mark) => {
const parent = mark.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(mark.textContent || ''), mark);
parent.normalize();
}
});
const q = debouncedSearch.trim().toLowerCase();
if (!q) return;
// Walk text nodes inside visible .rules-content and wrap matches
container.querySelectorAll('.rules-content').forEach((content) => {
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
while (walker.nextNode()) textNodes.push(walker.currentNode as Text);
textNodes.forEach((node) => {
const text = node.textContent || '';
const lower = text.toLowerCase();
if (!lower.includes(q)) return;
const frag = document.createDocumentFragment();
let lastIdx = 0;
let idx = lower.indexOf(q);
while (idx !== -1) {
if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
const mark = document.createElement('mark');
mark.className = 'search-hl';
mark.textContent = text.slice(idx, idx + q.length);
frag.appendChild(mark);
lastIdx = idx + q.length;
idx = lower.indexOf(q, lastIdx);
}
if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
node.parentNode?.replaceChild(frag, node);
});
});
}, [debouncedSearch, openSections]);
const toggle = (index: number) => { const toggle = (index: number) => {
setOpenSections((prev) => { setOpenSections((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -30,13 +132,15 @@ export default function RulesPage() {
}; };
const expandAll = () => { const expandAll = () => {
setOpenSections(new Set(filtered.map((s) => s.originalIndex))); setOpenSections(new Set(battleReferenceSections.map((_, i) => i)));
}; };
const collapseAll = () => { const collapseAll = () => {
setOpenSections(new Set()); setOpenSections(new Set());
}; };
const matchCount = matchSet?.size ?? battleReferenceSections.length;
return ( return (
<> <>
<TopBar title="Ship Reference" /> <TopBar title="Ship Reference" />
@@ -56,30 +160,28 @@ export default function RulesPage() {
<button className="btn-secondary btn-sm" onClick={collapseAll}> <button className="btn-secondary btn-sm" onClick={collapseAll}>
Collapse All Collapse All
</button> </button>
{search.trim() && ( {debouncedSearch.trim() && (
<span className="rules-count">{filtered.length} result{filtered.length !== 1 ? 's' : ''}</span> <span className="rules-count">
{matchCount} result{matchCount !== 1 ? 's' : ''}
</span>
)} )}
</div> </div>
<div className="rules-sections"> <div className="rules-sections" ref={sectionsRef}>
{filtered.map((section) => ( {battleReferenceSections.map((section, i) => (
<details key={section.originalIndex} className="rules-section" open={openSections.has(section.originalIndex)}> <details
<summary className="rules-summary" onClick={(e) => { e.preventDefault(); toggle(section.originalIndex); }}> key={i}
{section.title} className="rules-section"
open={openSections.has(i)}
style={matchSet !== null && !matchSet.has(i) ? { display: 'none' } : undefined}
>
<summary className="rules-summary" onClick={(e) => { e.preventDefault(); toggle(i); }}>
<HighlightText text={section.title} query={debouncedSearch} />
</summary> </summary>
<div className="rules-content"> <SectionContent content={section.content} />
<Markdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ children }) => (
<div className="table-wrap"><table>{children}</table></div>
),
}}
>{section.content}</Markdown>
</div>
</details> </details>
))} ))}
{filtered.length === 0 && ( {matchSet !== null && matchSet.size === 0 && (
<p className="empty-text" style={{ padding: 'var(--spacing-md) 0' }}> <p className="empty-text" style={{ padding: 'var(--spacing-md) 0' }}>
No sections match "{search}". No sections match "{search}".
</p> </p>