diff --git a/.jules/bolt.md b/.jules/bolt.md index 284d180a7..9870c47eb 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -19,3 +19,7 @@ ## 2026-07-13 - 단일 패스 문자열 치환 최적화 (O(N) 단일 스캔 및 지연 할당) **Learning:** `String.replace()`를 여러 번 체이닝하여 호출하면, 문자열 치환이 발생하지 않는 경우에도 내부적으로 불필요한 스캔이 중복 발생하고, 치환 시마다 새로운 문자열 객체와 char 배열이 할당되어 메모리 낭비와 성능 저하(GC 압박)가 발생한다. **Action:** 여러 문자를 한 번에 치환해야 하는 경우, O(N) 단일 스캔을 통해 `charAt()`으로 문자를 확인하고, 치환이 실제로 필요한 경우에만 `StringBuilder`를 지연 할당(Lazy allocation)하여 성능을 최적화하고 불필요한 메모리 할당을 방지한다. + +## 2024-10-14 - Optimizing DOM mutations with DocumentFragment +**Learning:** In frontend performance optimization, replacing loop-based `appendChild` calls with a `DocumentFragment` reduces redundant reflows. When optimizing utility functions (like `addDetailRow`), all internal call sites must be tracked down and updated to accept the fragment object to avoid `ReferenceError` crashes. Additionally, custom mock DOM setups in test files must implement a `MockDocumentFragment` that correctly pushes its `childNodes` upon `appendChild` in the parent node mock, instead of appending the fragment itself, simulating standard browser DOM behavior. +**Action:** When migrating UI renders to use `DocumentFragment`, always use tools like `grep -rn` to verify all utility call sites are updated, and ensure the mock environment fully supports `DocumentFragment` unpacking in `appendChild` before claiming victory. diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 42998be56..f9e19002d 100644 --- a/src/main/resources/static/assets/viewer/demo.js +++ b/src/main/resources/static/assets/viewer/demo.js @@ -114,6 +114,8 @@ function renderHistory(history = loadHistory()) { el.historyBody.textContent = ""; el.emptyHistory.hidden = history.length > 0; + const fragment = document.createDocumentFragment(); + for (const job of history) { const row = document.createElement("tr"); const fileCell = document.createElement("td"); @@ -143,18 +145,19 @@ 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 +180,21 @@ 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); + 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..dbf64f7f1 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", @@ -54,6 +54,9 @@ test("the executable demo renders inert actions and blocks repeated status activ createElement(tagName) { return new MockElement(tagName); }, + createDocumentFragment() { + return new MockDocumentFragment(); + }, }; globalThis.window = { confirm() { diff --git a/src/test/js/dom-utils.test.mjs b/src/test/js/dom-utils.test.mjs index 94b151063..9daf9358a 100644 --- a/src/test/js/dom-utils.test.mjs +++ b/src/test/js/dom-utils.test.mjs @@ -6,11 +6,14 @@ 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) { return new MockElement(tagName); + }, + createDocumentFragment() { + return new MockDocumentFragment(); } }; diff --git a/src/test/js/mock-dom.mjs b/src/test/js/mock-dom.mjs index 774116bc0..3e199525e 100644 --- a/src/test/js/mock-dom.mjs +++ b/src/test/js/mock-dom.mjs @@ -1,3 +1,30 @@ +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 MockTextNode { constructor(text) { this.type = "text"; @@ -31,12 +58,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) {