From b47771745a204f5f391cda7f49b9295969f3aaea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:47:40 +0900 Subject: [PATCH 01/32] perf: bound WBS badge template caches --- .jules/bolt.md | 6 + CHANGELOG.md | 7 + app.js | 70 ++++++--- docs/doctoring/dom-template-cache.md | 111 +++++++++++++ package.json | 6 +- tests/e2e/render-performance.spec.js | 177 +++++++++++++++++++++ tests/unit/caching.test.mjs | 225 +++++++++++++++++++++++++++ 7 files changed, 581 insertions(+), 21 deletions(-) create mode 100644 docs/doctoring/dom-template-cache.md create mode 100644 tests/e2e/render-performance.spec.js create mode 100644 tests/unit/caching.test.mjs diff --git a/.jules/bolt.md b/.jules/bolt.md index b08b203a..458984b6 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 static DOM structures in module-level Map templates +**Learning:** In high-frequency rendering loops, repeatedly calling `document.createElement` and configuring attributes node-by-node (like setting `.className`, `.textContent`, `.title`) causes significant JS-to-C++ DOM instantiation overhead. Caching these static/predictable node structures in a `Map` keyed by state (e.g. frozen state objects or discrete strings) and returning `.cloneNode(true)` eliminates redundant overhead. +**Action:** Use a module-level `Map` to cache fully-configured static DOM elements based on their inputs, then return `.cloneNode(true)` during hot path rendering. +## 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 787ee51b..f9732395 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 @@ -52,6 +55,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Reused bounded owner and status badge templates in the WBS table render path. + Cache entries are keyed by rendered semantics, evicted with a 256-entry LRU + bound, and cloned before use; owner colors are deterministic without retaining + an unbounded owner registry. - Attachment-list status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with per-item abortable timeouts and a request-wide latency budget, preserves stale status after downstream, diff --git a/app.js b/app.js index a04aae71..17dfc9ae 100644 --- a/app.js +++ b/app.js @@ -971,36 +971,70 @@ function createWarningBadge(warning) { return badge; } -const persistentOwnerColorMap = new Map(); +const BADGE_TEMPLATE_CACHE_LIMIT = 256; +const ownerBadgeTemplateMap = new Map(); +const statusBadgeTemplateMap = new Map(); + +function getCachedBadgeTemplate(cache, key, createTemplate) { + const cachedTemplate = cache.get(key); + if (cachedTemplate) { + cache.delete(key); + cache.set(key, cachedTemplate); + return cachedTemplate; + } + + const template = createTemplate(); + if (cache.size >= BADGE_TEMPLATE_CACHE_LIMIT) { + cache.delete(cache.keys().next().value); + } + cache.set(key, template); + return template; +} + +function getOwnerColor(owner) { + let hash = 0; + for (let index = 0; index < owner.length; index += 1) { + hash = ((hash << 5) - hash + owner.charCodeAt(index)) | 0; + } + return OWNER_COLORS[Math.abs(hash) % OWNER_COLORS.length]; +} function createOwnerCellContent(owner) { if (!owner) { return createEmptyCell(); } - if (!persistentOwnerColorMap.has(owner)) { - persistentOwnerColorMap.set(owner, OWNER_COLORS[persistentOwnerColorMap.size % OWNER_COLORS.length]); - } - - const badge = document.createElement('span'); - badge.className = 'owner-badge'; - badge.style.background = persistentOwnerColorMap.get(owner); - badge.textContent = owner; - return badge; + const template = getCachedBadgeTemplate(ownerBadgeTemplateMap, owner, () => { + const badge = document.createElement('span'); + badge.className = 'owner-badge'; + badge.style.background = getOwnerColor(owner); + badge.textContent = owner; + return badge; + }); + return template.cloneNode(true); } function createStatusCellContent(progressState) { if (!progressState.label) { return createEmptyCell(); } - const badge = document.createElement('span'); - badge.className = `status-badge ${progressState.className}`; - badge.textContent = progressState.label; - if (progressState.description) { - badge.title = progressState.description; - badge.setAttribute('aria-label', `${progressState.label} - ${progressState.description}`); - } - return badge; + + const cacheKey = JSON.stringify([ + progressState.label, + progressState.className, + progressState.description || '' + ]); + const template = getCachedBadgeTemplate(statusBadgeTemplateMap, cacheKey, () => { + const badge = document.createElement('span'); + badge.className = `status-badge ${progressState.className}`; + badge.textContent = progressState.label; + if (progressState.description) { + badge.title = progressState.description; + badge.setAttribute('aria-label', `${progressState.label} - ${progressState.description}`); + } + return badge; + }); + return template.cloneNode(true); } const metricTextTemplate = document.createElement('span'); diff --git a/docs/doctoring/dom-template-cache.md b/docs/doctoring/dom-template-cache.md new file mode 100644 index 00000000..a32826b7 --- /dev/null +++ b/docs/doctoring/dom-template-cache.md @@ -0,0 +1,111 @@ +# Bounded DOM template caching and browser evidence + +## Decision + +ScopeWeave may reuse unattached owner/status badge templates in the WBS render +loop when all of the following remain true: + +- every returned node is a clone rather than the cached node itself; +- the cache key contains every value that affects rendered text, class, title, + and accessible name; +- each cache is bounded to 256 entries and uses least-recently-used eviction; +- owner color is deterministic and does not require an unbounded owner 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 boundary + +`cloneNode()` copies the node and its attributes. Its `deep` argument controls +whether child nodes are copied; it does not make JavaScript extension properties +or listeners registered through `addEventListener()` transferable. Cached +ScopeWeave templates therefore contain only DOM state that is safe to clone, and +callers receive a distinct node before any row-specific mutation. + +Status-template identity is a serialized tuple of label, class name, and +description. This prevents two visually similar statuses with different +accessible explanations from sharing stale `title` or `aria-label` content. + +## Resource bound + +Both template maps have a hard 256-entry limit. A cache hit refreshes recency; +an insertion at capacity removes the least-recently-used key. This keeps +long-running workspaces with high-cardinality owner or status values from +retaining an unbounded collection of detached DOM nodes. + +Owner colors are derived from a deterministic integer hash and the fixed +`OWNER_COLORS` palette. The same owner remains visually stable without retaining +all historical owners in memory. + +## Test-first evidence + +The focused unit contract verifies: + +- semantically equal status objects share one entry; +- different descriptions cannot reuse stale accessible text; +- returned nodes are distinct clones; +- owner/status caches stay at or below 256 entries after high-cardinality input; +- 5,000 identical owners require only one template creation; and +- empty-value behavior remains unchanged. + +The Playwright benchmark drives the production bootstrap and rendering path with +5,000 rows. It records: + +- cold-load duration; +- five warm-render samples; +- median and p95 duration; +- long-task count and longest task; +- JavaScript heap deltas when the browser exposes them; +- live DOM-node counts; +- `document.createElement()` calls; and +- edit, drag, and inline-progress interaction success. + +A prior hosted run on the mature implementation recorded warm durations of +5,596.4, 6,654.9, 7,199.2, 5,724.1, and 6,133.5 milliseconds, with a 6,133.5 +millisecond median and 7,199.2 millisecond p95 for the 5,000-row path. It also +proved edit, drag, and inline-progress interactions. These numbers are execution +evidence, not a protected-base optimization delta. + +## Interpretation limit + +No protected-base browser A/B was executed in the same environment. The report +therefore emits: + +```json +{ + "protectedBaselineAvailable": false, + "targetPercent": 15, + "targetMet": null, + "optimizationDeltaPercent": null +} +``` + +Cold-load and warm-render values must not be compared as if they were before and +after measurements. A future performance claim requires randomized, repeated, +same-runner baseline and candidate samples with an explicit uncertainty model. +Until then, the merge gate proves bounded memory behavior, semantic parity, +production-path measurability, and interaction integrity rather than a claimed +percentage speedup. + +## Rollback + +Revert the template maps, helper, deterministic owner-color function, focused +unit contract, browser benchmark registration, CHANGELOG entries, 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/package.json b/package.json index 46d07bfb..f1ae25d5 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,12 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/caching.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/caching.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", - "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", + "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js tests/e2e/render-performance.spec.js", "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js", "fuzz": "node --test tests/fuzz/*.mjs" }, diff --git a/tests/e2e/render-performance.spec.js b/tests/e2e/render-performance.spec.js new file mode 100644 index 00000000..a274fad6 --- /dev/null +++ b/tests/e2e/render-performance.spec.js @@ -0,0 +1,177 @@ +import { test, expect } from '@playwright/test'; + +const ROW_COUNT = 5_000; +const SAMPLE_COUNT = 5; +const STORAGE_KEY = 'scopeweave:planner-state:v1'; + +function percentile(values, probability) { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * probability) - 1); + return sorted[index]; +} + +function createTask(index) { + return { + id: `performance-${index}`, + parentId: null, + depth: 1, + expanded: true, + pendingDelete: false, + isSynthetic: false, + phase: `Phase ${index}`, + activity: '', + task: '', + categoryLarge: '', + categoryMedium: '', + documentName: '', + owner: 'same-owner', + supportTeam: '', + plannedStartDate: '2026-01-01', + plannedEndDate: '2026-01-02', + actualProgressStatus: '미착수(0%)', + actualStartDate: '', + actualEndDate: '', + predecessors: '', + budget: '', + actualCost: '', + sprint: '', + storyPoints: '', + }; +} + +test('5,000-row production rendering remains measurable and interactive', async ({ page }) => { + test.setTimeout(120_000); + + await page.addInitScript(() => { + const originalCreateElement = Document.prototype.createElement; + let createElementCalls = 0; + Document.prototype.createElement = function (...args) { + createElementCalls += 1; + return originalCreateElement.apply(this, args); + }; + window.__scopeweaveCreateElementCalls = () => createElementCalls; + window.__scopeweaveLongTasks = []; + if (PerformanceObserver.supportedEntryTypes?.includes('longtask')) { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + window.__scopeweaveLongTasks.push(entry.duration); + } + }); + observer.observe({ type: 'longtask', buffered: true }); + } + }); + + await page.goto('/'); + const tasks = Array.from({ length: ROW_COUNT }, (_, index) => createTask(index)); + await page.evaluate(({ storageKey, seededTasks }) => { + localStorage.setItem(storageKey, JSON.stringify({ + projectName: 'ScopeWeave benchmark', + baseDate: '2026-01-01', + tasks: seededTasks, + })); + }, { storageKey: STORAGE_KEY, seededTasks: tasks }); + + const coldStartedAt = Date.now(); + await page.reload(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(ROW_COUNT); + const coldLoadDurationMs = Date.now() - coldStartedAt; + + const evidence = await page.evaluate(async ({ sampleCount }) => { + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + const projectName = document.getElementById('project-name'); + const samples = []; + + for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { + const createElementsBefore = window.__scopeweaveCreateElementCalls(); + const heapBefore = performance.memory?.usedJSHeapSize ?? null; + const startedAt = performance.now(); + projectName.focus(); + projectName.value = `ScopeWeave benchmark ${sampleIndex}`; + projectName.dispatchEvent(new Event('input', { bubbles: true })); + projectName.blur(); + await nextFrame(); + samples.push({ + durationMs: performance.now() - startedAt, + createElementCalls: window.__scopeweaveCreateElementCalls() - createElementsBefore, + heapDeltaBytes: heapBefore === null ? null : performance.memory.usedJSHeapSize - heapBefore, + liveDomNodes: document.getElementsByTagName('*').length, + }); + } + + const firstRow = document.querySelector('tr[data-task-id="performance-0"]'); + firstRow.querySelector('button[data-action="edit"]').click(); + const editOpened = Boolean(document.querySelector('form[data-editor-form="true"]')); + document.querySelector('button[data-action="cancel-editor"]').click(); + + let progressSelect = document.querySelector('select[data-inline-progress="performance-0"]'); + const nextProgress = progressSelect.options[Math.min(1, progressSelect.options.length - 1)].value; + progressSelect.value = nextProgress; + progressSelect.dispatchEvent(new Event('change', { bubbles: true })); + progressSelect = document.querySelector('select[data-inline-progress="performance-0"]'); + const inlineProgressChanged = progressSelect.value === nextProgress; + + const rowIds = () => Array.from(document.querySelectorAll('tr[data-task-id]'), (row) => row.dataset.taskId); + const orderBeforeDrag = rowIds().slice(0, 2); + const sourceRow = document.querySelector('tr[data-task-id="performance-0"]'); + const targetRow = document.querySelector('tr[data-task-id="performance-1"]'); + const dataTransfer = new DataTransfer(); + sourceRow.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer })); + const targetRect = targetRow.getBoundingClientRect(); + targetRow.dispatchEvent(new DragEvent('dragover', { + bubbles: true, + cancelable: true, + clientY: targetRect.bottom, + dataTransfer, + })); + targetRow.dispatchEvent(new DragEvent('drop', { + bubbles: true, + cancelable: true, + clientY: targetRect.bottom, + dataTransfer, + })); + sourceRow.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer })); + await nextFrame(); + const orderAfterDrag = rowIds().slice(0, 2); + + return { + samples, + longTasks: window.__scopeweaveLongTasks, + renderedRows: document.querySelectorAll('tr[data-task-id]').length, + editOpened, + inlineProgressChanged, + dragReordered: orderBeforeDrag.join(',') !== orderAfterDrag.join(','), + }; + }, { sampleCount: SAMPLE_COUNT }); + + const durations = evidence.samples.map((sample) => sample.durationMs); + const report = { + rowCount: ROW_COUNT, + sampleCount: SAMPLE_COUNT, + coldLoadDurationMs, + sampleDurationsMs: durations, + medianDurationMs: percentile(durations, 0.5), + p95DurationMs: percentile(durations, 0.95), + protectedBaselineAvailable: false, + targetPercent: 15, + targetMet: null, + optimizationDeltaPercent: null, + comparisonNote: 'No protected-base browser A/B was run; do not interpret cold-load versus warm-render timings as an optimization delta.', + longTaskCount: evidence.longTasks.length, + longestTaskMs: evidence.longTasks.length ? Math.max(...evidence.longTasks) : null, + heapDeltaBytes: evidence.samples.map((sample) => sample.heapDeltaBytes), + liveDomNodes: evidence.samples.map((sample) => sample.liveDomNodes), + createElementCalls: evidence.samples.map((sample) => sample.createElementCalls), + editOpened: evidence.editOpened, + inlineProgressChanged: evidence.inlineProgressChanged, + dragReordered: evidence.dragReordered, + }; + console.log(`SCOPEWEAVE_RENDER_BENCHMARK ${JSON.stringify(report)}`); + + expect(evidence.samples).toHaveLength(SAMPLE_COUNT); + expect(evidence.renderedRows).toBe(ROW_COUNT); + expect(report.medianDurationMs).toBeGreaterThan(0); + expect(report.p95DurationMs).toBeGreaterThanOrEqual(report.medianDurationMs); + expect(evidence.editOpened).toBe(true); + expect(evidence.inlineProgressChanged).toBe(true); + expect(evidence.dragReordered).toBe(true); +}); diff --git a/tests/unit/caching.test.mjs b/tests/unit/caching.test.mjs new file mode 100644 index 00000000..164b6b72 --- /dev/null +++ b/tests/unit/caching.test.mjs @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const appJsPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'app.js'); + +function loadApp() { + let createElementCalls = 0; + let source = fs.readFileSync(appJsPath, 'utf8'); + source = source.replace(/^\s*bootstrap\(\);\s*$/m, ';'); + source += ` +;globalThis.__cachingExports = { + createStatusCellContent, + createOwnerCellContent, + statusBadgeTemplateMap, + ownerBadgeTemplateMap, +}; +`; + + class DummyNode { + constructor(name) { + this.name = name; + this.attributes = Object.create(null); + this.style = Object.create(null); + this.children = []; + } + set className(value) { this.attributes.class = value; } + get className() { return this.attributes.class; } + set textContent(value) { this.text = value; } + get textContent() { return this.text; } + set title(value) { this.titleAttribute = value; } + get title() { return this.titleAttribute; } + setAttribute(key, value) { this.attributes[key] = value; } + appendChild(child) { this.children.push(child); } + append(...children) { this.children.push(...children); } + cloneNode(deep) { + const node = new DummyNode(this.name); + node.attributes = { ...this.attributes }; + node.style = { ...this.style }; + node.titleAttribute = this.titleAttribute; + if (deep) node.text = this.text; + return node; + } + } + + const dummyElement = new DummyNode('div'); + const classList = { + contains: () => false, + add() {}, + remove() {}, + toggle() {}, + }; + const proxyDummy = new Proxy(dummyElement, { + get(target, property) { + if (property === 'classList') return classList; + if (property in target) return target[property]; + return () => proxyDummy; + }, + set(target, property, value) { + target[property] = value; + return true; + }, + }); + + const sandbox = { + document: { + createElement: (name) => { + createElementCalls += 1; + return new DummyNode(name); + }, + getElementById: () => proxyDummy, + querySelector: () => proxyDummy, + querySelectorAll: () => [], + body: proxyDummy, + addEventListener() {}, + }, + window: { + addEventListener() {}, + setTimeout: () => 0, + clearTimeout: () => undefined, + confirm: () => true, + }, + localStorage: { + getItem: () => null, + setItem: () => undefined, + }, + console, + setTimeout: () => 0, + clearTimeout: () => undefined, + Math, + Object, + Array, + String, + Number, + Boolean, + Map, + Set, + WeakMap, + Symbol, + Error, + TypeError, + Date, + JSON, + Proxy, + Promise, + }; + sandbox.globalThis = sandbox; + + const context = vm.createContext(sandbox); + vm.runInContext(source, context, { filename: appJsPath }); + + return { + ...sandbox.__cachingExports, + getCreateElementCalls: () => createElementCalls, + }; +} + +const { + createStatusCellContent, + createOwnerCellContent, + statusBadgeTemplateMap, + ownerBadgeTemplateMap, + getCreateElementCalls, +} = loadApp(); + +const doneState = { + label: '완료', + className: 'done', + description: '실적이 모두 입력되어 완료된 작업입니다.', +}; + +const firstDoneCell = createStatusCellContent(doneState); +assert.equal(firstDoneCell.text, '완료'); +assert.equal(firstDoneCell.className, 'status-badge done'); +assert.equal(firstDoneCell.title, doneState.description); +assert.equal( + firstDoneCell.attributes['aria-label'], + `완료 - ${doneState.description}`, +); +assert.equal(statusBadgeTemplateMap.size, 1); + +const equivalentDoneCell = createStatusCellContent({ ...doneState }); +assert.notEqual(firstDoneCell, equivalentDoneCell); +assert.equal(equivalentDoneCell.text, '완료'); +assert.equal( + statusBadgeTemplateMap.size, + 1, + 'equivalent rendered status values share one semantic cache entry', +); + +const revisedDescription = '완료되었지만 검토가 필요한 작업입니다.'; +const revisedDoneCell = createStatusCellContent({ + ...doneState, + description: revisedDescription, +}); +assert.equal(revisedDoneCell.title, revisedDescription); +assert.equal( + revisedDoneCell.attributes['aria-label'], + `완료 - ${revisedDescription}`, +); +assert.equal( + statusBadgeTemplateMap.size, + 2, + 'different accessible descriptions cannot reuse stale cached text', +); + +for (let index = 0; index < 300; index += 1) { + createStatusCellContent({ + label: `status-${index}`, + className: `state-${index}`, + description: `description-${index}`, + }); +} +assert.ok( + statusBadgeTemplateMap.size <= 256, + 'status badge templates stay within the bounded cache budget', +); + +const emptyState = { label: '', className: '', description: '' }; +const emptyCell = createStatusCellContent(emptyState); +assert.equal(emptyCell.name, 'span'); +assert.equal(emptyCell.className, 'empty-cell'); + +const firstOwnerCell = createOwnerCellContent('홍길동'); +assert.equal(firstOwnerCell.text, '홍길동'); +assert.equal(firstOwnerCell.className, 'owner-badge'); +assert.ok(firstOwnerCell.style.background); +assert.equal(ownerBadgeTemplateMap.has('홍길동'), true); + +const secondOwnerCell = createOwnerCellContent('홍길동'); +assert.notEqual(firstOwnerCell, secondOwnerCell); +assert.equal(secondOwnerCell.text, '홍길동'); +assert.equal(secondOwnerCell.className, 'owner-badge'); +assert.equal(firstOwnerCell.style.background, secondOwnerCell.style.background); + +for (let index = 0; index < 300; index += 1) { + createOwnerCellContent(`owner-${index}`); +} +assert.ok( + ownerBadgeTemplateMap.size <= 256, + 'owner badge templates stay within the bounded cache budget', +); + +ownerBadgeTemplateMap.clear(); +const createElementCallsBeforeVolume = getCreateElementCalls(); +for (let rowIndex = 0; rowIndex < 5_000; rowIndex += 1) { + createOwnerCellContent('same-owner'); +} +assert.equal( + getCreateElementCalls() - createElementCallsBeforeVolume, + 1, + '5,000 identical owner rows create one DOM template and clone it thereafter', +); +assert.equal( + ownerBadgeTemplateMap.size, + 1, + '5,000 identical owner rows retain one bounded cache entry', +); + +const emptyOwner = createOwnerCellContent(''); +assert.equal(emptyOwner.className, 'empty-cell'); + +console.log('✓ bounded DOM template caching tests passed'); From b201a9a6b3ccdfda1409dade86637a7fbee0a73a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:06:15 +0900 Subject: [PATCH 02/32] test(perf): compare 5000-row render against protected base --- tests/e2e/render-performance.spec.js | 159 +++++++++++++++++++++------ 1 file changed, 126 insertions(+), 33 deletions(-) diff --git a/tests/e2e/render-performance.spec.js b/tests/e2e/render-performance.spec.js index a274fad6..d560c466 100644 --- a/tests/e2e/render-performance.spec.js +++ b/tests/e2e/render-performance.spec.js @@ -1,8 +1,12 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + import { test, expect } from '@playwright/test'; const ROW_COUNT = 5_000; const SAMPLE_COUNT = 5; const STORAGE_KEY = 'scopeweave:planner-state:v1'; +const TARGET_IMPROVEMENT_PERCENT = 15; function percentile(values, probability) { const sorted = [...values].sort((left, right) => left - right); @@ -39,8 +43,36 @@ function createTask(index) { }; } -test('5,000-row production rendering remains measurable and interactive', async ({ page }) => { - test.setTimeout(120_000); +function protectedBaseSha() { + const override = String(process.env.SCOPEWEAVE_BENCHMARK_BASE_SHA || '').trim(); + if (override) return override; + + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) return null; + const event = JSON.parse(readFileSync(eventPath, 'utf8')); + return event.pull_request?.base?.sha || null; +} + +function readGitFile(commitSha, path) { + if (!/^[a-f0-9]{40}$/.test(String(commitSha || ''))) { + throw new Error(`Invalid benchmark base SHA: ${commitSha || ''}`); + } + + const spec = `${commitSha}:${path}`; + try { + return execFileSync('git', ['show', spec], { encoding: 'utf8' }); + } catch { + execFileSync('git', ['fetch', '--depth=1', 'origin', commitSha], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return execFileSync('git', ['show', spec], { encoding: 'utf8' }); + } +} + +async function measureRenderer(browser, { appSource = null, label }) { + const context = await browser.newContext(); + const page = await context.newPage(); await page.addInitScript(() => { const originalCreateElement = Document.prototype.createElement; @@ -61,22 +93,32 @@ test('5,000-row production rendering remains measurable and interactive', async } }); + if (appSource !== null) { + await page.route('**/app.js', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript; charset=utf-8', + body: appSource, + }); + }); + } + await page.goto('/'); const tasks = Array.from({ length: ROW_COUNT }, (_, index) => createTask(index)); - await page.evaluate(({ storageKey, seededTasks }) => { + await page.evaluate(({ storageKey, seededTasks, benchmarkLabel }) => { localStorage.setItem(storageKey, JSON.stringify({ - projectName: 'ScopeWeave benchmark', + projectName: `ScopeWeave ${benchmarkLabel} benchmark`, baseDate: '2026-01-01', tasks: seededTasks, })); - }, { storageKey: STORAGE_KEY, seededTasks: tasks }); + }, { storageKey: STORAGE_KEY, seededTasks: tasks, benchmarkLabel: label }); const coldStartedAt = Date.now(); await page.reload(); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(ROW_COUNT); const coldLoadDurationMs = Date.now() - coldStartedAt; - const evidence = await page.evaluate(async ({ sampleCount }) => { + const evidence = await page.evaluate(async ({ sampleCount, benchmarkLabel }) => { const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); const projectName = document.getElementById('project-name'); const samples = []; @@ -86,7 +128,7 @@ test('5,000-row production rendering remains measurable and interactive', async const heapBefore = performance.memory?.usedJSHeapSize ?? null; const startedAt = performance.now(); projectName.focus(); - projectName.value = `ScopeWeave benchmark ${sampleIndex}`; + projectName.value = `ScopeWeave ${benchmarkLabel} benchmark ${sampleIndex}`; projectName.dispatchEvent(new Event('input', { bubbles: true })); projectName.blur(); await nextFrame(); @@ -141,37 +183,88 @@ test('5,000-row production rendering remains measurable and interactive', async inlineProgressChanged, dragReordered: orderBeforeDrag.join(',') !== orderAfterDrag.join(','), }; - }, { sampleCount: SAMPLE_COUNT }); + }, { sampleCount: SAMPLE_COUNT, benchmarkLabel: label }); - const durations = evidence.samples.map((sample) => sample.durationMs); - const report = { - rowCount: ROW_COUNT, - sampleCount: SAMPLE_COUNT, - coldLoadDurationMs, + await context.close(); + return { coldLoadDurationMs, evidence }; +} + +function summarizeMeasurement(measurement) { + const durations = measurement.evidence.samples.map((sample) => sample.durationMs); + const createElementCalls = measurement.evidence.samples.map((sample) => sample.createElementCalls); + return { + coldLoadDurationMs: measurement.coldLoadDurationMs, sampleDurationsMs: durations, medianDurationMs: percentile(durations, 0.5), p95DurationMs: percentile(durations, 0.95), - protectedBaselineAvailable: false, - targetPercent: 15, - targetMet: null, - optimizationDeltaPercent: null, - comparisonNote: 'No protected-base browser A/B was run; do not interpret cold-load versus warm-render timings as an optimization delta.', - longTaskCount: evidence.longTasks.length, - longestTaskMs: evidence.longTasks.length ? Math.max(...evidence.longTasks) : null, - heapDeltaBytes: evidence.samples.map((sample) => sample.heapDeltaBytes), - liveDomNodes: evidence.samples.map((sample) => sample.liveDomNodes), - createElementCalls: evidence.samples.map((sample) => sample.createElementCalls), - editOpened: evidence.editOpened, - inlineProgressChanged: evidence.inlineProgressChanged, - dragReordered: evidence.dragReordered, + medianCreateElementCalls: percentile(createElementCalls, 0.5), + longTaskCount: measurement.evidence.longTasks.length, + longestTaskMs: measurement.evidence.longTasks.length + ? Math.max(...measurement.evidence.longTasks) + : null, + heapDeltaBytes: measurement.evidence.samples.map((sample) => sample.heapDeltaBytes), + liveDomNodes: measurement.evidence.samples.map((sample) => sample.liveDomNodes), + createElementCalls, + editOpened: measurement.evidence.editOpened, + inlineProgressChanged: measurement.evidence.inlineProgressChanged, + dragReordered: measurement.evidence.dragReordered, + }; +} + +test('5,000-row production rendering beats the exact protected-base median by at least 15%', async ({ browser }) => { + test.setTimeout(180_000); + + const baseSha = protectedBaseSha(); + const baselineSource = baseSha ? readGitFile(baseSha, 'app.js') : null; + const optimizedMeasurement = await measureRenderer(browser, { label: 'optimized' }); + const optimized = summarizeMeasurement(optimizedMeasurement); + + let baseline = null; + let optimizationDeltaPercent = null; + let targetMet = null; + if (baselineSource !== null) { + baseline = summarizeMeasurement(await measureRenderer(browser, { + appSource: baselineSource, + label: 'protected-base', + })); + optimizationDeltaPercent = ((baseline.medianDurationMs - optimized.medianDurationMs) + / baseline.medianDurationMs) * 100; + targetMet = optimizationDeltaPercent >= TARGET_IMPROVEMENT_PERCENT; + } + + const report = { + rowCount: ROW_COUNT, + sampleCount: SAMPLE_COUNT, + protectedBaseSha: baseSha, + protectedBaselineAvailable: baseline !== null, + targetPercent: TARGET_IMPROVEMENT_PERCENT, + targetMet, + optimizationDeltaPercent, + baseline, + optimized, + comparisonNote: baseline === null + ? 'Set SCOPEWEAVE_BENCHMARK_BASE_SHA outside pull-request CI to enable exact-base A/B evidence.' + : 'Both variants use the same browser, current static shell, 5,000-row state, and render trigger; only app.js is replaced with the immutable PR base source for the baseline.', }; console.log(`SCOPEWEAVE_RENDER_BENCHMARK ${JSON.stringify(report)}`); - expect(evidence.samples).toHaveLength(SAMPLE_COUNT); - expect(evidence.renderedRows).toBe(ROW_COUNT); - expect(report.medianDurationMs).toBeGreaterThan(0); - expect(report.p95DurationMs).toBeGreaterThanOrEqual(report.medianDurationMs); - expect(evidence.editOpened).toBe(true); - expect(evidence.inlineProgressChanged).toBe(true); - expect(evidence.dragReordered).toBe(true); + expect(optimizedMeasurement.evidence.samples).toHaveLength(SAMPLE_COUNT); + expect(optimizedMeasurement.evidence.renderedRows).toBe(ROW_COUNT); + expect(optimized.medianDurationMs).toBeGreaterThan(0); + expect(optimized.p95DurationMs).toBeGreaterThanOrEqual(optimized.medianDurationMs); + expect(optimized.editOpened).toBe(true); + expect(optimized.inlineProgressChanged).toBe(true); + expect(optimized.dragReordered).toBe(true); + + if (baseline !== null) { + expect(baseline.medianDurationMs).toBeGreaterThan(0); + expect(baseline.editOpened).toBe(true); + expect(baseline.inlineProgressChanged).toBe(true); + expect(baseline.dragReordered).toBe(true); + expect(optimized.medianCreateElementCalls).toBeLessThan(baseline.medianCreateElementCalls); + expect( + optimizationDeltaPercent, + `expected >=${TARGET_IMPROVEMENT_PERCENT}% median render improvement over ${baseSha}, got ${optimizationDeltaPercent.toFixed(2)}%`, + ).toBeGreaterThanOrEqual(TARGET_IMPROVEMENT_PERCENT); + } }); From ff2b5bf22f86a8a9e8d34f52c7d950577459e27a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:15:27 +0900 Subject: [PATCH 03/32] test(perf): prove metadata edits preserve the 5k-row grid --- tests/e2e/render-performance.spec.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/e2e/render-performance.spec.js b/tests/e2e/render-performance.spec.js index d560c466..8bd0c8ff 100644 --- a/tests/e2e/render-performance.spec.js +++ b/tests/e2e/render-performance.spec.js @@ -54,15 +54,16 @@ function protectedBaseSha() { } function readGitFile(commitSha, path) { - if (!/^[a-f0-9]{40}$/.test(String(commitSha || ''))) { - throw new Error(`Invalid benchmark base SHA: ${commitSha || ''}`); + const normalizedCommitSha = String(commitSha || ''); + if (!/^[a-f0-9]{40}$/.test(normalizedCommitSha)) { + throw new Error(`Invalid benchmark base SHA: ${normalizedCommitSha || ''}`); } - const spec = `${commitSha}:${path}`; + const spec = `${normalizedCommitSha}:${path}`; try { return execFileSync('git', ['show', spec], { encoding: 'utf8' }); } catch { - execFileSync('git', ['fetch', '--depth=1', 'origin', commitSha], { + execFileSync('git', ['fetch', '--depth=1', 'origin', normalizedCommitSha], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); @@ -124,6 +125,7 @@ async function measureRenderer(browser, { appSource = null, label }) { const samples = []; for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { + const firstRowBeforeMetadataEdit = document.querySelector('tr[data-task-id="performance-0"]'); const createElementsBefore = window.__scopeweaveCreateElementCalls(); const heapBefore = performance.memory?.usedJSHeapSize ?? null; const startedAt = performance.now(); @@ -137,6 +139,7 @@ async function measureRenderer(browser, { appSource = null, label }) { createElementCalls: window.__scopeweaveCreateElementCalls() - createElementsBefore, heapDeltaBytes: heapBefore === null ? null : performance.memory.usedJSHeapSize - heapBefore, liveDomNodes: document.getElementsByTagName('*').length, + taskGridReused: firstRowBeforeMetadataEdit === document.querySelector('tr[data-task-id="performance-0"]'), }); } @@ -198,6 +201,7 @@ function summarizeMeasurement(measurement) { medianDurationMs: percentile(durations, 0.5), p95DurationMs: percentile(durations, 0.95), medianCreateElementCalls: percentile(createElementCalls, 0.5), + metadataTaskGridReused: measurement.evidence.samples.every((sample) => sample.taskGridReused), longTaskCount: measurement.evidence.longTasks.length, longestTaskMs: measurement.evidence.longTasks.length ? Math.max(...measurement.evidence.longTasks) @@ -250,6 +254,7 @@ test('5,000-row production rendering beats the exact protected-base median by at expect(optimizedMeasurement.evidence.samples).toHaveLength(SAMPLE_COUNT); expect(optimizedMeasurement.evidence.renderedRows).toBe(ROW_COUNT); + expect(optimized.metadataTaskGridReused).toBe(true); expect(optimized.medianDurationMs).toBeGreaterThan(0); expect(optimized.p95DurationMs).toBeGreaterThanOrEqual(optimized.medianDurationMs); expect(optimized.editOpened).toBe(true); From 1c4f29431dd30b1a57c0301427a6113b3e0c585e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:25:35 +0900 Subject: [PATCH 04/32] perf(render): avoid rebuilding task grid for project metadata --- app.js | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/app.js b/app.js index 17dfc9ae..6577100a 100644 --- a/app.js +++ b/app.js @@ -263,7 +263,11 @@ async function bootstrap() { } function bindEvents() { - const persistAndRenderMetadata = debounce(() => { + const persistProjectMetadata = debounce(() => { + persistState(); + renderProjectMetadata(); + }, 150); + const persistAndRenderPlan = debounce(() => { persistState(); renderAll(); }, 150); @@ -277,13 +281,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 +296,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 +506,21 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); } +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() { const metrics = computeTaskMetrics(); - 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; + renderProjectMetadata(); 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?.({ From e043b1d752fef512cc79571db2675737d6e7b1bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:13:41 +0900 Subject: [PATCH 05/32] test(perf): reject row data in badge template caches --- tests/unit/caching.test.mjs | 77 ++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/tests/unit/caching.test.mjs b/tests/unit/caching.test.mjs index 164b6b72..8770d8cb 100644 --- a/tests/unit/caching.test.mjs +++ b/tests/unit/caching.test.mjs @@ -14,8 +14,8 @@ function loadApp() { ;globalThis.__cachingExports = { createStatusCellContent, createOwnerCellContent, - statusBadgeTemplateMap, - ownerBadgeTemplateMap, + getStatusBadgeTemplate: () => statusBadgeTemplate, + getOwnerBadgeTemplate: () => ownerBadgeTemplate, }; `; @@ -120,8 +120,8 @@ function loadApp() { const { createStatusCellContent, createOwnerCellContent, - statusBadgeTemplateMap, - ownerBadgeTemplateMap, + getStatusBadgeTemplate, + getOwnerBadgeTemplate, getCreateElementCalls, } = loadApp(); @@ -139,16 +139,20 @@ assert.equal( firstDoneCell.attributes['aria-label'], `완료 - ${doneState.description}`, ); -assert.equal(statusBadgeTemplateMap.size, 1); +const statusShell = getStatusBadgeTemplate(); +assert.equal(statusShell.className, 'status-badge'); +assert.equal(statusShell.textContent, undefined, 'cached status shell must not retain row text'); +assert.equal(statusShell.title, undefined, 'cached status shell must not retain row title'); +assert.equal( + statusShell.attributes['aria-label'], + undefined, + 'cached status shell must not retain row accessibility text', +); const equivalentDoneCell = createStatusCellContent({ ...doneState }); assert.notEqual(firstDoneCell, equivalentDoneCell); assert.equal(equivalentDoneCell.text, '완료'); -assert.equal( - statusBadgeTemplateMap.size, - 1, - 'equivalent rendered status values share one semantic cache entry', -); +assert.equal(getStatusBadgeTemplate(), statusShell, 'status rendering reuses one immutable shell'); const revisedDescription = '완료되었지만 검토가 필요한 작업입니다.'; const revisedDoneCell = createStatusCellContent({ @@ -160,12 +164,10 @@ assert.equal( revisedDoneCell.attributes['aria-label'], `완료 - ${revisedDescription}`, ); -assert.equal( - statusBadgeTemplateMap.size, - 2, - 'different accessible descriptions cannot reuse stale cached text', -); +assert.equal(statusShell.textContent, undefined, 'status shell remains free of revised row text'); +assert.equal(statusShell.title, undefined, 'status shell remains free of revised row descriptions'); +const createElementCallsBeforeStatuses = getCreateElementCalls(); for (let index = 0; index < 300; index += 1) { createStatusCellContent({ label: `status-${index}`, @@ -173,10 +175,12 @@ for (let index = 0; index < 300; index += 1) { description: `description-${index}`, }); } -assert.ok( - statusBadgeTemplateMap.size <= 256, - 'status badge templates stay within the bounded cache budget', +assert.equal( + getCreateElementCalls() - createElementCallsBeforeStatuses, + 0, + 'status values clone one immutable shell without allocating per-value templates', ); +assert.equal(statusShell.textContent, undefined, 'status shell never retains customer status values'); const emptyState = { label: '', className: '', description: '' }; const emptyCell = createStatusCellContent(emptyState); @@ -185,41 +189,42 @@ assert.equal(emptyCell.className, 'empty-cell'); const firstOwnerCell = createOwnerCellContent('홍길동'); assert.equal(firstOwnerCell.text, '홍길동'); -assert.equal(firstOwnerCell.className, 'owner-badge'); -assert.ok(firstOwnerCell.style.background); -assert.equal(ownerBadgeTemplateMap.has('홍길동'), true); +assert.match(firstOwnerCell.className, /^owner-badge owner-badge--color-\d+$/); +assert.equal(firstOwnerCell.style.background, undefined, 'owner color must not use inline style'); +const ownerShell = getOwnerBadgeTemplate(); +assert.equal(ownerShell.className, 'owner-badge'); +assert.equal(ownerShell.textContent, undefined, 'cached owner shell must not retain user data'); +assert.equal(ownerShell.style.background, undefined, 'cached owner shell must not retain inline color'); const secondOwnerCell = createOwnerCellContent('홍길동'); assert.notEqual(firstOwnerCell, secondOwnerCell); assert.equal(secondOwnerCell.text, '홍길동'); -assert.equal(secondOwnerCell.className, 'owner-badge'); -assert.equal(firstOwnerCell.style.background, secondOwnerCell.style.background); +assert.equal(firstOwnerCell.className, secondOwnerCell.className, 'owner color class stays deterministic'); +assert.equal(getOwnerBadgeTemplate(), ownerShell, 'owner rendering reuses one immutable shell'); +const createElementCallsBeforeOwners = getCreateElementCalls(); for (let index = 0; index < 300; index += 1) { - createOwnerCellContent(`owner-${index}`); + const ownerCell = createOwnerCellContent(`owner-${index}`); + assert.match(ownerCell.className, /^owner-badge owner-badge--color-\d+$/); } -assert.ok( - ownerBadgeTemplateMap.size <= 256, - 'owner badge templates stay within the bounded cache budget', +assert.equal( + getCreateElementCalls() - createElementCallsBeforeOwners, + 0, + 'unique owner values clone one immutable shell without allocating user-keyed templates', ); +assert.equal(ownerShell.textContent, undefined, 'owner shell never retains customer owner values'); -ownerBadgeTemplateMap.clear(); const createElementCallsBeforeVolume = getCreateElementCalls(); for (let rowIndex = 0; rowIndex < 5_000; rowIndex += 1) { createOwnerCellContent('same-owner'); } assert.equal( getCreateElementCalls() - createElementCallsBeforeVolume, - 1, - '5,000 identical owner rows create one DOM template and clone it thereafter', -); -assert.equal( - ownerBadgeTemplateMap.size, - 1, - '5,000 identical owner rows retain one bounded cache entry', + 0, + '5,000 identical owner rows clone the existing immutable shell without new elements', ); const emptyOwner = createOwnerCellContent(''); assert.equal(emptyOwner.className, 'empty-cell'); -console.log('✓ bounded DOM template caching tests passed'); +console.log('✓ immutable DOM badge shell caching tests passed'); From 711f34a2c425ad48b1acd4095525c243475ba4cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:19:50 +0900 Subject: [PATCH 06/32] fix(perf): cache immutable badge shells only --- app.js | 89 ++++++++++++++++++++++------------------------------------ 1 file changed, 34 insertions(+), 55 deletions(-) diff --git a/app.js b/app.js index 6577100a..bfb5b839 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%)', @@ -265,7 +260,7 @@ async function bootstrap() { function bindEvents() { const persistProjectMetadata = debounce(() => { persistState(); - renderProjectMetadata(); + renderAll({ metadataOnly: true }); }, 150); const persistAndRenderPlan = debounce(() => { persistState(); @@ -514,10 +509,13 @@ function renderProjectMetadata() { } const cachedHasChildrenSet = new Set(); -function renderAll() { - const metrics = computeTaskMetrics(); - +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); @@ -979,32 +977,17 @@ function createWarningBadge(warning) { return badge; } -const BADGE_TEMPLATE_CACHE_LIMIT = 256; -const ownerBadgeTemplateMap = new Map(); -const statusBadgeTemplateMap = new Map(); - -function getCachedBadgeTemplate(cache, key, createTemplate) { - const cachedTemplate = cache.get(key); - if (cachedTemplate) { - cache.delete(key); - cache.set(key, cachedTemplate); - return cachedTemplate; - } - - const template = createTemplate(); - if (cache.size >= BADGE_TEMPLATE_CACHE_LIMIT) { - cache.delete(cache.keys().next().value); - } - cache.set(key, template); - return template; -} +// 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 getOwnerColor(owner) { +function getOwnerColorIndex(owner) { let hash = 0; for (let index = 0; index < owner.length; index += 1) { hash = ((hash << 5) - hash + owner.charCodeAt(index)) | 0; } - return OWNER_COLORS[Math.abs(hash) % OWNER_COLORS.length]; + return Math.abs(hash) % OWNER_COLOR_CLASS_COUNT; } function createOwnerCellContent(owner) { @@ -1012,14 +995,14 @@ function createOwnerCellContent(owner) { return createEmptyCell(); } - const template = getCachedBadgeTemplate(ownerBadgeTemplateMap, owner, () => { - const badge = document.createElement('span'); - badge.className = 'owner-badge'; - badge.style.background = getOwnerColor(owner); - badge.textContent = owner; - return badge; - }); - return template.cloneNode(true); + if (!ownerBadgeTemplate) { + ownerBadgeTemplate = document.createElement('span'); + ownerBadgeTemplate.className = 'owner-badge'; + } + const badge = ownerBadgeTemplate.cloneNode(false); + badge.className = `owner-badge owner-badge--color-${getOwnerColorIndex(owner)}`; + badge.textContent = owner; + return badge; } function createStatusCellContent(progressState) { @@ -1027,22 +1010,18 @@ function createStatusCellContent(progressState) { return createEmptyCell(); } - const cacheKey = JSON.stringify([ - progressState.label, - progressState.className, - progressState.description || '' - ]); - const template = getCachedBadgeTemplate(statusBadgeTemplateMap, cacheKey, () => { - const badge = document.createElement('span'); - badge.className = `status-badge ${progressState.className}`; - badge.textContent = progressState.label; - if (progressState.description) { - badge.title = progressState.description; - badge.setAttribute('aria-label', `${progressState.label} - ${progressState.description}`); - } - return badge; - }); - return template.cloneNode(true); + 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) { + badge.title = progressState.description; + badge.setAttribute('aria-label', `${progressState.label} - ${progressState.description}`); + } + return badge; } const metricTextTemplate = document.createElement('span'); From 5ded4af5f21d0c2fad86fd648407a15ddd9035fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:22:57 +0900 Subject: [PATCH 07/32] fix(ui): move owner badge colors to stylesheet --- styles.css | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/styles.css b/styles.css index 9d715f00..ca55224c 100644 --- a/styles.css +++ b/styles.css @@ -404,6 +404,27 @@ select:focus-visible, text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); } +.owner-badge--color-0 { background: #3f51b5; } +.owner-badge--color-1 { background: #8e24aa; } +.owner-badge--color-2 { background: #d81b60; } +.owner-badge--color-3 { background: #ef6c00; } +.owner-badge--color-4 { background: #6d4c41; } +.owner-badge--color-5 { background: #00897b; } +.owner-badge--color-6 { background: #1e88e5; } +.owner-badge--color-7 { background: #3949ab; } +.owner-badge--color-8 { background: #7cb342; } +.owner-badge--color-9 { background: #f4511e; } +.owner-badge--color-10 { background: #5e35b1; } +.owner-badge--color-11 { background: #c0ca33; } +.owner-badge--color-12 { background: #00acc1; } +.owner-badge--color-13 { background: #fb8c00; } +.owner-badge--color-14 { background: #546e7a; } +.owner-badge--color-15 { background: #43a047; } +.owner-badge--color-16 { background: #e53935; } +.owner-badge--color-17 { background: #6a1b9a; } +.owner-badge--color-18 { background: #039be5; } +.owner-badge--color-19 { background: #5d4037; } + .status-badge.before { background: #f1f5f9; color: var(--status-before); } .status-badge.active { background: #d1fae5; color: #047857; } .status-badge.done { background: #e2e8f0; color: var(--status-done); } From d5e0e1056fb01b74d8eabcf1e0dc390f8b192206 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:23:45 +0900 Subject: [PATCH 08/32] test(perf): require zero allocation on every metadata sample --- tests/e2e/render-performance.spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/render-performance.spec.js b/tests/e2e/render-performance.spec.js index 8bd0c8ff..0910c286 100644 --- a/tests/e2e/render-performance.spec.js +++ b/tests/e2e/render-performance.spec.js @@ -255,6 +255,9 @@ test('5,000-row production rendering beats the exact protected-base median by at expect(optimizedMeasurement.evidence.samples).toHaveLength(SAMPLE_COUNT); expect(optimizedMeasurement.evidence.renderedRows).toBe(ROW_COUNT); expect(optimized.metadataTaskGridReused).toBe(true); + for (const sample of optimizedMeasurement.evidence.samples) { + expect(sample.createElementCalls).toBe(0); + } expect(optimized.medianDurationMs).toBeGreaterThan(0); expect(optimized.p95DurationMs).toBeGreaterThanOrEqual(optimized.medianDurationMs); expect(optimized.editOpened).toBe(true); From e7ad20d918b1f3697e37f4ad638bb6a6a6ee1c83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:24:28 +0900 Subject: [PATCH 09/32] docs(perf): require immutable DOM cache shells --- .jules/bolt.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 458984b6..ebf10f08 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -4,9 +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 static DOM structures in module-level Map templates -**Learning:** In high-frequency rendering loops, repeatedly calling `document.createElement` and configuring attributes node-by-node (like setting `.className`, `.textContent`, `.title`) causes significant JS-to-C++ DOM instantiation overhead. Caching these static/predictable node structures in a `Map` keyed by state (e.g. frozen state objects or discrete strings) and returning `.cloneNode(true)` eliminates redundant overhead. -**Action:** Use a module-level `Map` to cache fully-configured static DOM elements based on their inputs, then return `.cloneNode(true)` during hot path rendering. +## 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. From dd799cdaa033810f6a5ca95fcedc3af275aea494 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:25:10 +0900 Subject: [PATCH 10/32] docs(perf): record immutable badge shell boundary --- docs/doctoring/dom-template-cache.md | 164 +++++++++++++-------------- 1 file changed, 81 insertions(+), 83 deletions(-) diff --git a/docs/doctoring/dom-template-cache.md b/docs/doctoring/dom-template-cache.md index a32826b7..6324e4a9 100644 --- a/docs/doctoring/dom-template-cache.md +++ b/docs/doctoring/dom-template-cache.md @@ -1,100 +1,98 @@ -# Bounded DOM template caching and browser evidence +# Immutable DOM badge shells and browser evidence -## Decision +## Decision status -ScopeWeave may reuse unattached owner/status badge templates in the WBS render -loop when all of the following remain true: +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 node itself; -- the cache key contains every value that affects rendered text, class, title, - and accessible name; -- each cache is bounded to 256 entries and uses least-recently-used eviction; -- owner color is deterministic and does not require an unbounded owner registry; +- 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 boundary - -`cloneNode()` copies the node and its attributes. Its `deep` argument controls -whether child nodes are copied; it does not make JavaScript extension properties -or listeners registered through `addEventListener()` transferable. Cached -ScopeWeave templates therefore contain only DOM state that is safe to clone, and -callers receive a distinct node before any row-specific mutation. - -Status-template identity is a serialized tuple of label, class name, and -description. This prevents two visually similar statuses with different -accessible explanations from sharing stale `title` or `aria-label` content. - -## Resource bound - -Both template maps have a hard 256-entry limit. A cache hit refreshes recency; -an insertion at capacity removes the least-recently-used key. This keeps -long-running workspaces with high-cardinality owner or status values from -retaining an unbounded collection of detached DOM nodes. - -Owner colors are derived from a deterministic integer hash and the fixed -`OWNER_COLORS` palette. The same owner remains visually stable without retaining -all historical owners in memory. +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. -## Test-first evidence +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. -The focused unit contract verifies: +## Evidence interpretation -- semantically equal status objects share one entry; -- different descriptions cannot reuse stale accessible text; -- returned nodes are distinct clones; -- owner/status caches stay at or below 256 entries after high-cardinality input; -- 5,000 identical owners require only one template creation; and -- empty-value behavior remains unchanged. +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. -The Playwright benchmark drives the production bootstrap and rendering path with -5,000 rows. It records: - -- cold-load duration; -- five warm-render samples; -- median and p95 duration; -- long-task count and longest task; -- JavaScript heap deltas when the browser exposes them; -- live DOM-node counts; -- `document.createElement()` calls; and -- edit, drag, and inline-progress interaction success. - -A prior hosted run on the mature implementation recorded warm durations of -5,596.4, 6,654.9, 7,199.2, 5,724.1, and 6,133.5 milliseconds, with a 6,133.5 -millisecond median and 7,199.2 millisecond p95 for the 5,000-row path. It also -proved edit, drag, and inline-progress interactions. These numbers are execution -evidence, not a protected-base optimization delta. - -## Interpretation limit - -No protected-base browser A/B was executed in the same environment. The report -therefore emits: - -```json -{ - "protectedBaselineAvailable": false, - "targetPercent": 15, - "targetMet": null, - "optimizationDeltaPercent": null -} -``` - -Cold-load and warm-render values must not be compared as if they were before and -after measurements. A future performance claim requires randomized, repeated, -same-runner baseline and candidate samples with an explicit uncertainty model. -Until then, the merge gate proves bounded memory behavior, semantic parity, -production-path measurability, and interaction integrity rather than a claimed -percentage speedup. +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 template maps, helper, deterministic owner-color function, focused -unit contract, browser benchmark registration, CHANGELOG entries, and this -record together. A rollback does not change persisted WBS data or server APIs. +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 From fbe0291a85d961e55734fcf728e5128c242b38f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:25:52 +0900 Subject: [PATCH 11/32] docs(changelog): record immutable badge shells --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8752d104..ba0947b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,10 +56,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Reused bounded owner and status badge templates in the WBS table render path. - Cache entries are keyed by rendered semantics, evicted with a 256-entry LRU - bound, and cloned before use; owner colors are deterministic without retaining - an unbounded owner registry. +- 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 + truncated input. - Attachment-list status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with per-item abortable timeouts and a request-wide latency budget, preserves stale status after downstream, From 323a2d4dfa23ec2e639327095f3df423661f236b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:28:12 +0900 Subject: [PATCH 12/32] merge(perf): preserve current protected develop behavior --- cloud-sync.js | 57 +++++++++++------ .../ms-project-xml-import-boundary.md | 63 +++++++++++++++++++ docs/security.md | 2 +- tests/unit/msproject.test.mjs | 49 +++++++++++++++ 4 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 docs/doctoring/ms-project-xml-import-boundary.md diff --git a/cloud-sync.js b/cloud-sync.js index 9016cfbf..0e015ebe 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -741,33 +741,54 @@ function openReportModal() { export function parseMsProjectXml(xml) { // Fully linear extract (indexOf/slice) — no dynamic RegExp and no lazy // [\s\S]*? block collectors (those can quadratic-backtrack on truncated input). + const isXmlWhitespace = (charCode) => ( + charCode === 0x20 || charCode === 0x09 || charCode === 0x0d || charCode === 0x0a + ); + const findTagBoundary = (source, name, from, closing = false) => { + const prefix = `<${closing ? '/' : ''}${name}`; + let searchFrom = from; + for (;;) { + const start = source.indexOf(prefix, searchFrom); + if (start === -1) return null; + let delimiter = start + prefix.length; + while (delimiter < source.length && isXmlWhitespace(source.charCodeAt(delimiter))) { + delimiter += 1; + } + if (source.charCodeAt(delimiter) === 0x3e) { + return { start, end: delimiter + 1 }; + } + // Reject attributes, longer names, and non-XML whitespace while advancing + // past every inspected byte so malformed candidates are never rescanned. + searchFrom = Math.max(delimiter + 1, start + prefix.length); + } + }; const tag = (block, name) => { - const openingTag = `<${name}>`; - const closingTag = ``; - const valueStart = block.indexOf(openingTag); - if (valueStart === -1) return ''; - const contentStart = valueStart + openingTag.length; - const valueEnd = block.indexOf(closingTag, contentStart); - return valueEnd === -1 ? '' : block.slice(contentStart, valueEnd).trim(); + const opening = findTagBoundary(block, name, 0); + if (!opening) return ''; + const closing = findTagBoundary(block, name, opening.end, true); + const nextOpening = findTagBoundary(block, name, opening.end); + if (!closing || (nextOpening && nextOpening.start < closing.start)) return ''; + return block.slice(opening.end, closing.start).trim(); }; - const collectBlocks = (source, openTag, closeTag) => { + const collectBlocks = (source, name) => { const out = []; let from = 0; for (;;) { - const start = source.indexOf(openTag, from); - if (start === -1) break; - const contentStart = start + openTag.length; - const end = source.indexOf(closeTag, contentStart); - // Incomplete open tag: stop linearly (do not rescan the remainder). - if (end === -1) break; - out.push(source.slice(start, end + closeTag.length)); - from = end + closeTag.length; + const opening = findTagBoundary(source, name, from); + if (!opening) break; + const closing = findTagBoundary(source, name, opening.end, true); + const nextOpening = findTagBoundary(source, name, opening.end); + // Incomplete or nested same-name block: stop at the first unmatched + // opening tag instead of pairing it with a later block's closing tag. + if (!closing || (nextOpening && nextOpening.start < closing.start)) break; + out.push(source.slice(opening.start, closing.end)); + from = closing.end; } return out; }; const predecessorIds = (block) => { const ids = []; - for (const link of collectBlocks(block, '', '')) { + for (const link of collectBlocks(block, 'PredecessorLink')) { const uid = tag(link, 'PredecessorUID'); if (/^\d+$/.test(uid)) ids.push(`msp-${uid}`); } @@ -779,7 +800,7 @@ export function parseMsProjectXml(xml) { const day = (s) => (/^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : ''); const tasks = []; const parents = {}; // depth -> last task id at that depth - const blocks = collectBlocks(String(xml || ''), '', ''); + const blocks = collectBlocks(String(xml || ''), 'Task'); for (const block of blocks) { const uid = tag(block, 'UID'); const name = unescape(tag(block, 'Name')); diff --git a/docs/doctoring/ms-project-xml-import-boundary.md b/docs/doctoring/ms-project-xml-import-boundary.md new file mode 100644 index 00000000..0a8fa5aa --- /dev/null +++ b/docs/doctoring/ms-project-xml-import-boundary.md @@ -0,0 +1,63 @@ +# Microsoft Project XML delimiter boundary + +## Decision + +ScopeWeave's Microsoft Project import profile accepts XML whitespace between an +exact supported element name and the closing `>` delimiter. The accepted code +points are: + +- U+0020 SPACE; +- U+0009 CHARACTER TABULATION; +- U+000D CARRIAGE RETURN; and +- U+000A LINE FEED. + +The parser deliberately does not become a general XML processor. It recognizes +only the exact `Task`, `PredecessorLink`, and scalar element names already used +by the import adapter. Attributes, namespace prefixes, longer lookalike names, +non-XML whitespace, self-closing forms, nested same-name blocks, and truncated +blocks are rejected or yield no value under this narrow profile. + +## Security and complexity boundary + +The scanner remains monotonic and regex-free. It advances through every rejected +candidate and uses bounded `indexOf()` and `slice()` operations rather than +constructing dynamic regular expressions or lazy whole-document block matches. +This preserves the existing denial-of-service boundary for malformed or +adversarial uploads. + +An unmatched outer element cannot consume a later nested element's closing tag. +If another same-name opening appears before the candidate closing tag, block +collection stops at the unmatched outer element instead of silently producing a +mis-parented task. + +## Executable evidence + +`tests/unit/msproject.test.mjs` covers: + +- space, tab, carriage-return, and line-feed delimiters; +- scalar and predecessor-link elements using each allowed delimiter; +- an actual U+000B vertical tab, which is not XML whitespace; +- attributes and longer element names; +- truncated and repeated unclosed task blocks; +- nested same-name openings before a closing element; and +- the existing valid import and predecessor contracts. + +The test is already part of the full unit and coverage command paths. No package +or lockfile change is required. + +## Compatibility and rollback + +The change broadens acceptance only for documents that are conformant with the +XML whitespace production at the delimiter positions used by this adapter. +Existing byte-exact exports retain the same task identifiers, names, dates, +parents, progress, and predecessor values. + +Rollback must revert the scanner, focused tests, security documentation, +CHANGELOG entry, and this record together. Reintroducing byte-exact delimiters +would again reject standards-compliant Microsoft Project exports that contain +formatting whitespace before `>`. + +## Reference + +World Wide Web Consortium. (2008). *Extensible Markup Language (XML) 1.0 +(Fifth Edition)*. https://www.w3.org/TR/2008/REC-xml-20081126/ diff --git a/docs/security.md b/docs/security.md index 5b21a5e6..0ceee972 100644 --- a/docs/security.md +++ b/docs/security.md @@ -19,7 +19,7 @@ Every user-controlled CSV cell is neutralized when, after optional leading white ## XML imports -Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking. +Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Opening and closing `Task`, `PredecessorLink`, and scalar tags accept only XML whitespace (space, tab, carriage return, or line feed) between the exact element name and `>`. Attributes, longer names, and other whitespace code points are not accepted by this deliberately narrow import profile. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking. ## Release verification diff --git a/tests/unit/msproject.test.mjs b/tests/unit/msproject.test.mjs index d829a32c..284cb51d 100644 --- a/tests/unit/msproject.test.mjs +++ b/tests/unit/msproject.test.mjs @@ -72,4 +72,53 @@ assert.deepEqual( const incompleteOpens = `${'9open'.repeat(5000)}`; assert.deepEqual(parseMsProjectXml(incompleteOpens), [], 'unclosed Task blocks yield no tasks'); +assert.deepEqual( + parseMsProjectXml( + '11unclosed outer12nested', + ), + [], + 'an unmatched outer Task cannot consume a nested Task closing tag', +); + +const whitespaceTags = parseMsProjectXml(` + + 8 + Whitespace-compatible task + 1 + 2026-08-11T09:00:00 + 2026-08-12T17:00:00 + + 2 + +`); +assert.equal(whitespaceTags.length, 1, 'XML whitespace before tag delimiters is accepted'); +assert.equal(whitespaceTags[0].id, 'msp-8'); +assert.equal(whitespaceTags[0].phase, 'Whitespace-compatible task'); +assert.equal(whitespaceTags[0].plannedStartDate, '2026-08-11'); +assert.equal(whitespaceTags[0].plannedEndDate, '2026-08-12'); +assert.equal(whitespaceTags[0].predecessors, 'msp-2', 'block and scalar tags share the scanner'); + +assert.deepEqual( + parseMsProjectXml('9wrong'), + [], + 'TaskX must not match Task', +); +assert.deepEqual( + parseMsProjectXml('9wrong whitespace'), + [], + 'non-XML whitespace before a delimiter is rejected', +); +assert.deepEqual( + parseMsProjectXml('10truncated'), + [], + 'truncated whitespace-delimited Task stops safely', +); +assert.deepEqual( + parseMsProjectXml( + '13outerinner1', + ), + [], + 'a nested scalar opening cannot consume the inner closing delimiter', +); + console.log('✓ MS Project import tests passed'); From 61b9d6e7e04751bc6e4959403cb51a83177050dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 02:58:17 +0900 Subject: [PATCH 13/32] test(perf): prove progress template clone isolation --- tests/unit/caching.test.mjs | 66 +++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/tests/unit/caching.test.mjs b/tests/unit/caching.test.mjs index 8770d8cb..9efe5e1c 100644 --- a/tests/unit/caching.test.mjs +++ b/tests/unit/caching.test.mjs @@ -14,8 +14,10 @@ function loadApp() { ;globalThis.__cachingExports = { createStatusCellContent, createOwnerCellContent, + createActualProgressCellContent, getStatusBadgeTemplate: () => statusBadgeTemplate, getOwnerBadgeTemplate: () => ownerBadgeTemplate, + getActualProgressSelectTemplate: () => actualProgressSelectTemplate, }; `; @@ -23,6 +25,7 @@ function loadApp() { constructor(name) { this.name = name; this.attributes = Object.create(null); + this.dataset = Object.create(null); this.style = Object.create(null); this.children = []; } @@ -38,9 +41,15 @@ function loadApp() { cloneNode(deep) { const node = new DummyNode(this.name); node.attributes = { ...this.attributes }; + node.dataset = { ...this.dataset }; node.style = { ...this.style }; node.titleAttribute = this.titleAttribute; - if (deep) node.text = this.text; + node.id = this.id; + node.value = this.value; + if (deep) { + node.text = this.text; + node.children = this.children.map((child) => child.cloneNode(true)); + } return node; } } @@ -120,8 +129,10 @@ function loadApp() { const { createStatusCellContent, createOwnerCellContent, + createActualProgressCellContent, getStatusBadgeTemplate, getOwnerBadgeTemplate, + getActualProgressSelectTemplate, getCreateElementCalls, } = loadApp(); @@ -227,4 +238,55 @@ assert.equal( const emptyOwner = createOwnerCellContent(''); assert.equal(emptyOwner.className, 'empty-cell'); -console.log('✓ immutable DOM badge shell caching tests passed'); +// Issue #409 also requires the pre-existing cached progress