diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml
index 458d3aa9..d404d0df 100644
--- a/.github/workflows/server-tests.yml
+++ b/.github/workflows/server-tests.yml
@@ -55,3 +55,5 @@ jobs:
run: npx playwright install chromium --with-deps
- name: Cloud UI e2e
run: npm run test:e2e:cloud
+ - name: Metadata render performance benchmark
+ run: npm run test:e2e:performance
diff --git a/.jules/bolt.md b/.jules/bolt.md
index b08b203a..ebf10f08 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -4,3 +4,9 @@
## 2026-07-12 - Optimize renderTaskRow DOM allocations
**Learning:** Caching unattached template nodes and instantiating them via `.cloneNode(false)` reduces DOM instantiation overhead in O(N) render loops significantly.
**Action:** Apply this optimization to other hot-path rendering elements such as rows, cells, and stack containers.
+## 2026-07-13 - Cache immutable DOM shells, not input-bearing nodes
+**Learning:** In high-frequency rendering loops, repeated `document.createElement()` calls and repeated configuration of static structure create avoidable DOM bridge and GC overhead. Templates must contain only immutable, non-customer-specific structure. Caching fully configured nodes keyed by owner names, labels, descriptions, titles, or accessible names retains row/user data in detached DOM and turns input cardinality into memory retention.
+**Action:** Cache one bounded immutable shell per structural element type, clone it in the hot path, and apply row-specific text, classes, titles, and accessibility attributes only to the returned clone. Use fixed stylesheet classes for deterministic visual variants instead of inline styles or input-keyed DOM caches.
+## 2026-07-13 - Correctly caching element attributes with cloneNode
+**Learning:** Both `Node.cloneNode(false)` and `Node.cloneNode(true)` copy HTML attributes and their values, including reflected properties such as `title`. The `deep` argument controls only whether child nodes are cloned. JavaScript extension properties and listeners registered with `addEventListener()` are not cloned.
+**Action:** Select shallow or deep cloning from the required child-node structure, not to preserve reflected attributes. Reapply JavaScript extension properties and event listeners explicitly when cached templates require them.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e434fa01..46d57002 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added workflow ownership regression coverage so central review
workflows stay inherited from `ContextualWisdomLab/.github`, not copied
into this repository.
+- Added a reproducible 5,000-row production-browser rendering benchmark that
+ records median and p95 duration, long tasks, heap deltas, live DOM nodes,
+ element creation, and edit, drag, and inline-progress interaction evidence.
### Security
@@ -61,6 +64,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
mode, delegating provider/model/topology policy to the shared service without
weakening ScopeWeave's authenticated, fail-closed transport or response
boundary controls.
+- Reused one immutable owner-badge shell and one immutable status-badge shell in
+ the WBS table render path. Row text, accessible descriptions, and deterministic
+ owner color classes are applied only after cloning, so detached templates do
+ not retain task/user values or inline color styles.
- Accepted XML whitespace before exact Microsoft Project element delimiters
while preserving the linear, regex-free import scanner and rejecting
attributes, longer names, non-XML whitespace, nested unmatched blocks, and
diff --git a/app.js b/app.js
index a04aae71..7915bf89 100644
--- a/app.js
+++ b/app.js
@@ -2,12 +2,7 @@ const STORAGE_KEY = 'scopeweave:planner-state:v1';
const DEFAULT_PROJECT_NAME = 'ScopeWeave Planner';
const MAX_PROJECT_NAME_LENGTH = 120;
const MAX_BASE_DATE_LENGTH = 10;
-const OWNER_COLORS = [
- '#3f51b5', '#8e24aa', '#d81b60', '#ef6c00', '#6d4c41',
- '#00897b', '#1e88e5', '#3949ab', '#7cb342', '#f4511e',
- '#5e35b1', '#c0ca33', '#00acc1', '#fb8c00', '#546e7a',
- '#43a047', '#e53935', '#6a1b9a', '#039be5', '#5d4037'
-];
+const OWNER_COLOR_CLASS_COUNT = 20;
const ACTUAL_PROGRESS_OPTIONS = [
'미착수(0%)',
@@ -263,7 +258,11 @@ async function bootstrap() {
}
function bindEvents() {
- const persistAndRenderMetadata = debounce(() => {
+ const persistProjectMetadata = debounce(() => {
+ persistState();
+ renderAll({ metadataOnly: true });
+ }, 150);
+ const persistAndRenderPlan = debounce(() => {
persistState();
renderAll();
}, 150);
@@ -277,13 +276,13 @@ function bindEvents() {
return true;
};
- bindHeaderEvents(persistAndRenderMetadata);
+ bindHeaderEvents(persistProjectMetadata, persistAndRenderPlan);
bindModalEvents();
bindGlobalEvents();
bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent);
}
-function bindHeaderEvents(persistAndRenderMetadata) {
+function bindHeaderEvents(persistProjectMetadata, persistAndRenderPlan) {
elements.projectNameInput.addEventListener('input', (event) => {
const sanitized = String(event.target.value).slice(0, MAX_PROJECT_NAME_LENGTH);
if (event.target.value !== sanitized) {
@@ -292,15 +291,15 @@ function bindHeaderEvents(persistAndRenderMetadata) {
event.target.setSelectionRange(cursor, cursor);
}
state.projectName = sanitized.trim() || DEFAULT_PROJECT_NAME;
- persistAndRenderMetadata();
+ persistProjectMetadata();
});
- elements.projectNameInput.addEventListener('blur', persistAndRenderMetadata.flush);
+ elements.projectNameInput.addEventListener('blur', persistProjectMetadata.flush);
elements.baseDateInput.addEventListener('input', (event) => {
state.baseDate = String(event.target.value).trim().slice(0, MAX_BASE_DATE_LENGTH) || formatLocalDateInput(new Date());
- persistAndRenderMetadata();
+ persistAndRenderPlan();
});
- elements.baseDateInput.addEventListener('blur', persistAndRenderMetadata.flush);
+ elements.baseDateInput.addEventListener('blur', persistAndRenderPlan.flush);
elements.addRootButton.addEventListener('click', () => openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }));
elements.exportCsvButton.addEventListener('click', (e) => {
@@ -502,17 +501,24 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) {
});
}
-const cachedHasChildrenSet = new Set();
-function renderAll() {
- const metrics = computeTaskMetrics();
-
+function renderProjectMetadata() {
elements.projectNameInput.value = state.projectName;
document.title = state.projectName === DEFAULT_PROJECT_NAME ? DEFAULT_PROJECT_NAME : `${state.projectName} - ${DEFAULT_PROJECT_NAME}`;
elements.baseDateInput.value = state.baseDate;
+ elements.syncStatus.textContent = state.jsonSyncHandle ? '연결된 wbs.json 파일에 자동저장 중' : '브라우저 로컬 자동저장 사용 중';
+}
+
+const cachedHasChildrenSet = new Set();
+function renderAll({ metadataOnly = false } = {}) {
+ renderProjectMetadata();
+ if (metadataOnly) {
+ return;
+ }
+
+ const metrics = computeTaskMetrics();
elements.totalDays.textContent = `${formatNumber(metrics.totalDays)}일`;
elements.plannedProgress.textContent = formatPercent(metrics.totalWeightedPlannedRatio * 100, 2);
elements.actualProgress.textContent = formatPercent(metrics.totalWeightedActualRatio * 100, 2);
- elements.syncStatus.textContent = state.jsonSyncHandle ? '연결된 wbs.json 파일에 자동저장 중' : '브라우저 로컬 자동저장 사용 중';
if (typeof window !== 'undefined') {
window.ScopeWeaveAnalytics?.render?.({
@@ -971,21 +977,32 @@ function createWarningBadge(warning) {
return badge;
}
-const persistentOwnerColorMap = new Map();
+// Badge templates are immutable shells only. Customer/task text and accessible names
+// are applied to each clone after cloning so cached detached nodes never retain row data.
+let ownerBadgeTemplate = null;
+let statusBadgeTemplate = null;
+
+function getOwnerColorIndex(owner) {
+ let hash = 0;
+ for (let index = 0; index < owner.length; index += 1) {
+ hash = ((hash << 5) - hash + owner.charCodeAt(index)) | 0;
+ }
+ return (hash >>> 0) % OWNER_COLOR_CLASS_COUNT;
+}
function createOwnerCellContent(owner) {
if (!owner) {
return createEmptyCell();
}
+ const ownerValue = String(owner);
- if (!persistentOwnerColorMap.has(owner)) {
- persistentOwnerColorMap.set(owner, OWNER_COLORS[persistentOwnerColorMap.size % OWNER_COLORS.length]);
+ if (!ownerBadgeTemplate) {
+ ownerBadgeTemplate = document.createElement('span');
+ ownerBadgeTemplate.className = 'owner-badge';
}
-
- const badge = document.createElement('span');
- badge.className = 'owner-badge';
- badge.style.background = persistentOwnerColorMap.get(owner);
- badge.textContent = owner;
+ const badge = ownerBadgeTemplate.cloneNode(false);
+ badge.className = `owner-badge owner-badge--color-${getOwnerColorIndex(ownerValue)}`;
+ badge.textContent = ownerValue;
return badge;
}
@@ -993,7 +1010,12 @@ function createStatusCellContent(progressState) {
if (!progressState.label) {
return createEmptyCell();
}
- const badge = document.createElement('span');
+
+ if (!statusBadgeTemplate) {
+ statusBadgeTemplate = document.createElement('span');
+ statusBadgeTemplate.className = 'status-badge';
+ }
+ const badge = statusBadgeTemplate.cloneNode(false);
badge.className = `status-badge ${progressState.className}`;
badge.textContent = progressState.label;
if (progressState.description) {
diff --git a/docs/doctoring/dom-template-cache.md b/docs/doctoring/dom-template-cache.md
new file mode 100644
index 00000000..6324e4a9
--- /dev/null
+++ b/docs/doctoring/dom-template-cache.md
@@ -0,0 +1,109 @@
+# Immutable DOM badge shells and browser evidence
+
+## Decision status
+
+This record describes an **active pull-request implementation**, not protected-`develop`
+truth until integration completes. ScopeWeave may reuse unattached owner/status badge
+shells in the WBS render loop only when all of the following remain true:
+
+- every returned node is a clone rather than the cached shell itself;
+- cached shells contain no task, owner, status, title, description, accessible name,
+ or other row-specific value;
+- row-specific text, classes, `title`, and `aria-label` values are applied only after
+ cloning;
+- owner colors use a fixed set of stylesheet classes rather than inline styles or an
+ owner-value registry;
+- empty cells and warning paths keep their existing semantics; and
+- production-browser interaction tests accompany allocation-focused unit tests.
+
+The optimization is deliberately limited to small immutable badge structures.
+Editable controls, validation relationships, and elements whose event listeners or
+mutable child state differ per row are not cached here.
+
+## DOM correctness and privacy boundary
+
+`cloneNode()` copies the node and its attributes. Its `deep` argument controls whether
+child nodes are copied; it does not transfer listeners registered through
+`addEventListener()`. ScopeWeave therefore keeps the two cached badge shells free of
+row-specific attributes and child text, clones them shallowly, and mutates only the
+returned clone.
+
+This boundary is also a data-retention control. Owner names and status explanations
+are not used as DOM-cache keys and are not retained in detached template nodes.
+High-cardinality customer values therefore cannot grow a detached-node cache or leave
+historical row text in reusable templates.
+
+## Resource bound and deterministic color
+
+The owner badge uses one immutable shell and the status badge uses one immutable
+shell. Their memory bound is therefore structural rather than an input-cardinality
+LRU limit. A deterministic integer hash maps an owner string to one of 20 fixed
+`owner-badge--color-N` classes defined in `styles.css`; the shell itself contains no
+owner value and no inline `background` style.
+
+This supersedes the earlier 256-entry input-keyed owner/status template maps. That
+approach bounded entry count but still retained task/user data in cache keys and
+detached DOM nodes.
+
+## Metadata-only render integration
+
+Project-name persistence remains on the single user-visible `renderAll()` integration
+path. `renderAll({ metadataOnly: true })` refreshes project metadata and returns before
+metric calculation, analytics, visible-task construction, and task-grid replacement.
+Base-date changes continue through the full render path because they affect schedule
+metrics.
+
+## Test-first evidence contract
+
+The focused unit contract verifies that:
+
+- cached owner/status shells do not retain row text, title, accessible name, or inline
+ color;
+- returned nodes are distinct clones populated with the correct current row value;
+- a changed status description appears on the returned clone without mutating the
+ cached shell;
+- 300 unique status values and 300 unique owners allocate no new template elements
+ after their respective shell is initialized;
+- 5,000 identical owners likewise allocate no new template elements after shell
+ initialization; and
+- empty-value behavior remains unchanged.
+
+The Playwright benchmark drives the production bootstrap and rendering path with 5,000
+rows. For each warm project-name edit it records duration, `document.createElement()`
+calls, heap delta when available, live DOM-node count, and whether the first task-row
+node retained identity. The candidate contract requires **every** warm metadata sample
+to create zero elements and preserve task-grid identity. Edit, inline-progress, and
+drag/reorder probes remain acceptance checks in the same browser run.
+
+## Evidence interpretation
+
+A prior hosted A/B run demonstrated a large metadata-edit improvement against its then
+protected base, but predecessor-head or predecessor-base success is not exact-current-
+head evidence. After any source, test, documentation, stylesheet, or base-reconciliation
+change, the PR must regenerate browser and repository-native evidence for the unchanged
+exact contributor head and independently resolved live protected base before the result
+can support merge or release.
+
+Cold-load and long-task values remain diagnostic unless the benchmark is explicitly
+designed and powered for claims about those outcomes. The performance claim for this
+slice is limited to the project-name metadata-edit hot path.
+
+## Rollback
+
+Revert the immutable badge-shell helpers, fixed owner color classes, focused unit
+contract, metadata-sample assertion, browser benchmark registration, changelog entry,
+and this record together. A rollback does not change persisted WBS data or server APIs.
+
+## References
+
+Mozilla. (2026). *Node: cloneNode() method*. MDN Web Docs.
+https://developer.mozilla.org/en-US/docs/Web/API/Node/cloneNode
+
+Web Hypertext Application Technology Working Group. (2026). *DOM standard*.
+https://dom.spec.whatwg.org/
+
+World Wide Web Consortium. (2017). *Long Tasks API 1*.
+https://www.w3.org/TR/longtasks-1/
+
+World Wide Web Consortium. (2024). *High Resolution Time Level 3*.
+https://www.w3.org/TR/hr-time-3/
diff --git a/index.html b/index.html
index d24b2a88..acce6789 100644
--- a/index.html
+++ b/index.html
@@ -6,6 +6,8 @@