diff --git a/.jules/bolt.md b/.jules/bolt.md index 284d180a7..9a26fc7b4 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -19,3 +19,6 @@ ## 2026-07-13 - 단일 패스 문자열 치환 최적화 (O(N) 단일 스캔 및 지연 할당) **Learning:** `String.replace()`를 여러 번 체이닝하여 호출하면, 문자열 치환이 발생하지 않는 경우에도 내부적으로 불필요한 스캔이 중복 발생하고, 치환 시마다 새로운 문자열 객체와 char 배열이 할당되어 메모리 낭비와 성능 저하(GC 압박)가 발생한다. **Action:** 여러 문자를 한 번에 치환해야 하는 경우, O(N) 단일 스캔을 통해 `charAt()`으로 문자를 확인하고, 치환이 실제로 필요한 경우에만 `StringBuilder`를 지연 할당(Lazy allocation)하여 성능을 최적화하고 불필요한 메모리 할당을 방지한다. +## 2026-10-06 - DOM 렌더링 최적화: DocumentFragment를 통한 일괄 처리 (Batching) +**Learning:** `demo.js`의 `renderHistory`와 `renderJobDetail`에서 배열이나 요소를 순회하면서 개별적으로 DOM에 `appendChild`나 `append`를 호출하면, 매 삽입마다 브라우저의 리플로우(Reflow)와 리페인트(Repaint)가 트리거되어 렌더링 성능이 저하될 수 있습니다. +**Action:** 여러 DOM 노드를 추가할 때는 항상 `document.createDocumentFragment()`를 생성하여 메모리 상에서 모든 자식 노드를 부착한 후, 최종적으로 실제 DOM에 단 한 번만 삽입하도록 개선합니다. diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 42998be56..b69e53fa5 100644 --- a/src/main/resources/static/assets/viewer/demo.js +++ b/src/main/resources/static/assets/viewer/demo.js @@ -114,6 +114,10 @@ function renderHistory(history = loadHistory()) { el.historyBody.textContent = ""; el.emptyHistory.hidden = history.length > 0; + // ⚡ Bolt: Batch DOM row insertions using DocumentFragment + // Impact: Prevents N browser reflows/repaints by building the entire list in memory, then flushing to DOM in 1 operation. + const fragment = document.createDocumentFragment(); + for (const job of history) { const row = document.createElement("tr"); const fileCell = document.createElement("td"); @@ -143,18 +147,20 @@ function renderHistory(history = loadHistory()) { } row.append(fileCell, statusCell, submittedCell, actionsCell); - el.historyBody.appendChild(row); + fragment.appendChild(row); } + el.historyBody.appendChild(fragment); + renderRecoveryEvidence(history); } -function addDetailRow(label, value) { +function addDetailRow(fragment, label, value) { const term = document.createElement("dt"); const description = document.createElement("dd"); term.textContent = label; description.textContent = formatDetailValue(value); - el.jobDetailBody.append(term, description); + fragment.append(term, description); } function formatDetailValue(value) { @@ -177,17 +183,23 @@ function renderJobDetail(detail) { : "Operational evidence"; el.jobDetailBody.textContent = ""; - addDetailRow("Job ID", detail.jobId); - addDetailRow("Tenant", detail.tenantId); - addDetailRow("Status", detail.status); - addDetailRow("Message", detail.message); - addDetailRow("Attempts", `${detail.attemptCount ?? 0} / ${detail.maxAttempts ?? "n/a"}`); - addDetailRow("Dead-lettered", Boolean(detail.deadLettered)); - addDetailRow("Retry at", detail.retryAt); - addDetailRow("Created", detail.createdAt); - addDetailRow("Started", detail.startedAt); - addDetailRow("Completed", detail.completedAt); - addDetailRow("Artifact", detail.convertedResourcePath); + // ⚡ Bolt: Batch detail row DOM insertions using DocumentFragment + // Impact: Prevents N browser reflows/repaints for each detail attribute, writing to the DOM once per panel open. + const fragment = document.createDocumentFragment(); + + addDetailRow(fragment, "Job ID", detail.jobId); + addDetailRow(fragment, "Tenant", detail.tenantId); + addDetailRow(fragment, "Status", detail.status); + addDetailRow(fragment, "Message", detail.message); + addDetailRow(fragment, "Attempts", `${detail.attemptCount ?? 0} / ${detail.maxAttempts ?? "n/a"}`); + addDetailRow(fragment, "Dead-lettered", Boolean(detail.deadLettered)); + addDetailRow(fragment, "Retry at", detail.retryAt); + addDetailRow(fragment, "Created", detail.createdAt); + addDetailRow(fragment, "Started", detail.startedAt); + addDetailRow(fragment, "Completed", detail.completedAt); + addDetailRow(fragment, "Artifact", detail.convertedResourcePath); + + el.jobDetailBody.appendChild(fragment); el.retryJobBtn.hidden = !detail.deadLettered; } diff --git a/src/test/js/demo-integration.test.mjs b/src/test/js/demo-integration.test.mjs index 0f0437ea2..ae9075670 100644 --- a/src/test/js/demo-integration.test.mjs +++ b/src/test/js/demo-integration.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { MockElement } from "./mock-dom.mjs"; +import { MockElement, MockDocumentFragment } from "./mock-dom.mjs"; const elementIds = [ "upload-form", @@ -51,6 +51,9 @@ test("the executable demo renders inert actions and blocks repeated status activ getElementById(id) { return elements.get(id); }, + createDocumentFragment() { + return new MockDocumentFragment(); + }, createElement(tagName) { return new MockElement(tagName); }, @@ -122,7 +125,10 @@ test("the executable demo renders inert actions and blocks repeated status activ document: { title: "", body: popupBody, - createElement(tagName) { + createDocumentFragment() { + return new MockDocumentFragment(); + }, + createElement(tagName) { return new MockElement(tagName); }, }, diff --git a/src/test/js/dom-utils.test.mjs b/src/test/js/dom-utils.test.mjs index 94b151063..191492061 100644 --- a/src/test/js/dom-utils.test.mjs +++ b/src/test/js/dom-utils.test.mjs @@ -6,10 +6,13 @@ import { createLink, setBusyState } from "../../main/resources/static/assets/viewer/dom-utils.js"; -import { MockElement, MockTextNode } from "./mock-dom.mjs"; +import { MockElement, MockTextNode, MockDocumentFragment } from "./mock-dom.mjs"; globalThis.document = { - createElement(tagName) { + createDocumentFragment() { + return new MockDocumentFragment(); + }, + createElement(tagName) { return new MockElement(tagName); } }; diff --git a/src/test/js/mock-dom.mjs b/src/test/js/mock-dom.mjs index 774116bc0..3d98d6480 100644 --- a/src/test/js/mock-dom.mjs +++ b/src/test/js/mock-dom.mjs @@ -5,6 +5,31 @@ export class MockTextNode { } } +export class MockDocumentFragment { + constructor() { + this.childNodes = []; + } + appendChild(node) { + if (node instanceof MockDocumentFragment) { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + return node; + } + this.childNodes.push(node); + return node; + } + append(...nodes) { + for (const node of nodes) { + if (node instanceof MockDocumentFragment) { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + } else { + this.childNodes.push(node); + } + } + } +} + export class MockElement { constructor(tagName = "div") { this.tagName = tagName.toUpperCase(); @@ -31,12 +56,24 @@ export class MockElement { } appendChild(node) { + if (node instanceof MockDocumentFragment) { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + return node; + } this.childNodes.push(node); return node; } append(...nodes) { - this.childNodes.push(...nodes); + for (const node of nodes) { + if (node instanceof MockDocumentFragment) { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + } else { + this.childNodes.push(node); + } + } } replaceChildren(...nodes) {