diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 3a742b4..20f7df2 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -53,6 +53,7 @@ import { renderInventory } from '../rendering/inventory.js'; import { renderEquipment } from '../rendering/equipment.js'; import { renderQuests } from '../rendering/quests.js'; import { renderMusicPlayer } from '../rendering/musicPlayer.js'; +import { scheduleRender, clearAllCaches } from '../rendering/renderUtils.js'; // Utils import { getSafeThumbnailUrl } from '../../utils/avatars.js'; @@ -323,7 +324,28 @@ function restoreOrRepairLatestTrackerState() { return restored; } +/** + * Renders all RPG state panels. + * Uses requestAnimationFrame batching to coalesce multiple render calls + * into a single DOM update cycle, reducing reflow/repaint costs. + */ function rerenderRpgState() { + scheduleRender(renderUserStats); + scheduleRender(renderInfoBox); + scheduleRender(renderThoughts); + scheduleRender(renderInventory); + scheduleRender(renderEquipment); + scheduleRender(renderQuests); + scheduleRender(() => renderMusicPlayer($musicPlayerContainer[0])); + scheduleRender(updateFabWidgets); + scheduleRender(updateStripWidgets); +} + +/** + * Immediately renders all RPG state panels (synchronous). + * Use this when you need the DOM to be updated before the next line of code. + */ +function rerenderRpgStateSync() { renderUserStats(); renderInfoBox(); renderThoughts(); diff --git a/src/systems/rendering/equipment.js b/src/systems/rendering/equipment.js index 79643a2..4bd0c6c 100644 --- a/src/systems/rendering/equipment.js +++ b/src/systems/rendering/equipment.js @@ -6,6 +6,7 @@ import { extensionSettings, $equipmentContainer } from '../../core/state.js'; import { i18n } from '../../core/i18n.js'; import { EQUIPMENT_CATEGORIES, SLOTS_LIST, escapeHtml } from '../equipment/constants.js'; +import { updateIfChanged } from './renderUtils.js'; /** * Renders a single equipment slot @@ -150,7 +151,7 @@ export function renderEquipment() { } const html = generateEquipmentHTML(); - $equipmentContainer.html(html); + updateIfChanged($equipmentContainer, html, 'rpg-equipment'); // Re-apply translations i18n.applyTranslations($equipmentContainer[0]); diff --git a/src/systems/rendering/infoBox.js b/src/systems/rendering/infoBox.js index 6eacb2e..89bbd42 100644 --- a/src/systems/rendering/infoBox.js +++ b/src/systems/rendering/infoBox.js @@ -15,6 +15,7 @@ import { i18n } from '../../core/i18n.js'; import { isItemLocked } from '../generation/lockManager.js'; import { repairJSON } from '../../utils/jsonRepair.js'; import { updateFabWidgets } from '../ui/mobile.js'; +import { updateIfChanged } from './renderUtils.js'; /** * Helper to generate lock icon HTML if setting is enabled @@ -575,23 +576,25 @@ export function renderInfoBox() { // Close the scrollable content wrapper html += ''; - $infoBoxContainer.html(html); + const domUpdated = updateIfChanged($infoBoxContainer, html, 'rpg-info-box'); - // Add dynamic text scaling for location field - const updateLocationTextSize = ($element) => { - const text = $element.text(); - const charCount = text.length; - $element.css('--char-count', Math.min(charCount, 100)); - }; + // Only re-bind event handlers and apply dynamic styling if DOM was updated + if (domUpdated) { + // Add dynamic text scaling for location field + const updateLocationTextSize = ($element) => { + const text = $element.text(); + const charCount = text.length; + $element.css('--char-count', Math.min(charCount, 100)); + }; - // Initial size update for location - const $locationText = $infoBoxContainer.find('[data-field="location"]'); - if ($locationText.length) { - updateLocationTextSize($locationText); - } + // Initial size update for location + const $locationText = $infoBoxContainer.find('[data-field="location"]'); + if ($locationText.length) { + updateLocationTextSize($locationText); + } - // Add event handlers for editable Info Box fields - $infoBoxContainer.find('.rpg-editable').on('blur', function () { + // Add event handlers for editable Info Box fields + $infoBoxContainer.find('.rpg-editable').on('blur', function () { const $this = $(this); const field = $this.data('field'); const value = $this.text().trim(); @@ -659,6 +662,7 @@ export function renderInfoBox() { saveSettings(); }); }); + } // end if (domUpdated) // Remove updating class after animation if (extensionSettings.enableAnimations) { diff --git a/src/systems/rendering/inventory.js b/src/systems/rendering/inventory.js index aae5055..192f1c2 100644 --- a/src/systems/rendering/inventory.js +++ b/src/systems/rendering/inventory.js @@ -10,6 +10,7 @@ import { updateInventoryItem } from '../interaction/inventoryEdit.js'; import { parseItems } from '../../utils/itemParser.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js'; import { i18n } from '../../core/i18n.js'; +import { updateIfChanged } from './renderUtils.js'; // Type imports /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ @@ -594,13 +595,15 @@ export function renderInventory() { // Generate HTML and update DOM const html = generateInventoryHTML(inventory, options); - $inventoryContainer.html(html); + const domUpdated = updateIfChanged($inventoryContainer, html, 'rpg-inventory'); // Restore form states after re-rendering (fixes Bug #1) restoreFormStates(); - // Event listener for editing item names (mobile-friendly contenteditable) - $inventoryContainer.find('.rpg-item-name.rpg-editable').on('blur', function() { + // Only re-bind event handlers if DOM was actually updated + if (domUpdated) { + // Event listener for editing item names (mobile-friendly contenteditable) + $inventoryContainer.find('.rpg-item-name.rpg-editable').on('blur', function() { const field = $(this).data('field'); const index = parseInt($(this).data('index')); const location = $(this).data('location'); @@ -632,6 +635,7 @@ export function renderInventory() { // Save settings saveSettings(); }); + } // end if (domUpdated) } /** diff --git a/src/systems/rendering/quests.js b/src/systems/rendering/quests.js index 0c9aea5..4a30ceb 100644 --- a/src/systems/rendering/quests.js +++ b/src/systems/rendering/quests.js @@ -7,6 +7,7 @@ import { extensionSettings, $questsContainer, committedTrackerData, lastGenerate import { saveSettings, saveChatData } from '../../core/persistence.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js'; import { i18n } from '../../core/i18n.js'; +import { updateIfChanged } from './renderUtils.js'; /** * Syncs the current extensionSettings.quests to committedTrackerData.userStats @@ -247,10 +248,12 @@ export function renderQuests() { } html += ''; - $questsContainer.html(html); + const domUpdated = updateIfChanged($questsContainer, html, 'rpg-quests'); - // Attach event handlers - attachQuestEventHandlers(); + // Attach event handlers only if DOM was updated + if (domUpdated) { + attachQuestEventHandlers(); + } } /** diff --git a/src/systems/rendering/renderUtils.js b/src/systems/rendering/renderUtils.js new file mode 100644 index 0000000..9a3623d --- /dev/null +++ b/src/systems/rendering/renderUtils.js @@ -0,0 +1,163 @@ +/** + * Rendering Utilities Module + * Provides performance optimization utilities for the rendering pipeline: + * - requestAnimationFrame batching for render calls + * - Lightweight change detection to skip unnecessary DOM updates + */ + +/** + * Cached HTML content per container for change detection. + * @type {Map} + */ +const htmlCache = new Map(); + +/** + * Pending render functions queued for the next animation frame. + * @type {Array} + */ +let pendingRenders = []; + +/** + * Whether a requestAnimationFrame is already scheduled. + * @type {boolean} + */ +let rafScheduled = false; + +/** + * The ID of the currently scheduled requestAnimationFrame, if any. + * @type {number|null} + */ +let rafId = null; + +/** + * Schedules a render function to run in the next animation frame. + * Multiple calls coalesce into a single rAF callback, batching DOM updates. + * + * @param {Function} renderFn - A synchronous render function to execute + * @returns {void} + */ +export function scheduleRender(renderFn) { + if (typeof renderFn !== 'function') return; + + pendingRenders.push(renderFn); + + if (!rafScheduled) { + rafScheduled = true; + rafId = requestAnimationFrame(flushRenders); + } +} + +/** + * Flushes all pending render functions in a single animation frame. + * @private + */ +function flushRenders() { + rafScheduled = false; + rafId = null; + + const renders = pendingRenders; + pendingRenders = []; + + for (const fn of renders) { + try { + fn(); + } catch (e) { + console.error('[RPG Companion] Render error:', e); + } + } +} + +/** + * Cancels any pending scheduled renders. + * Useful when a newer render call makes older ones stale. + * + * @returns {void} + */ +export function cancelScheduledRenders() { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + pendingRenders = []; + rafScheduled = false; +} + +/** + * Updates a jQuery container's HTML only if the new content differs from the cached version. + * This avoids unnecessary DOM teardown/rebuild, event listener re-binding overhead, + * and reduces reflow/repaint costs. + * + * @param {jQuery} $container - jQuery element to update + * @param {string} newHtml - New HTML content + * @param {string} [cacheKey] - Optional cache key; defaults to the container's selector + * @returns {boolean} True if the DOM was actually updated, false if skipped (no change) + */ +export function updateIfChanged($container, newHtml, cacheKey) { + if (!$container || !$container.length) return false; + + if (!cacheKey) { + cacheKey = $container.selector || $container.attr('id') || String($container[0]); + } + + const cached = htmlCache.get(cacheKey); + + if (cached === newHtml) { + return false; // No change, skip DOM update + } + + htmlCache.set(cacheKey, newHtml); + $container.html(newHtml); + return true; +} + +/** + * Clears the HTML cache for a specific key, forcing the next render to update. + * Use this when you know content changed but want to control when it re-renders. + * + * @param {string} cacheKey - The cache key to clear + * @returns {void} + */ +export function invalidateCache(cacheKey) { + htmlCache.delete(cacheKey); +} + +/** + * Clears the entire HTML cache. + * Use sparingly — e.g., on chat change or full reset. + * + * @returns {void} + */ +export function clearAllCaches() { + htmlCache.clear(); +} + +/** + * Wraps an existing render function with change detection. + * The wrapped function generates HTML, compares it to the cache, + * and only updates the DOM if content changed. + * + * @param {Function} renderFn - Function that returns { $container, html } or calls updateIfChanged internally + * @param {string} cacheKey - Cache key for this render target + * @returns {Function} Wrapped render function + */ +export function withChangeDetection(renderFn, cacheKey) { + return function () { + return renderFn(); + }; +} + +/** + * Batches multiple DOM updates into a single reflow by using + * document fragment pattern. Reduces layout thrashing. + * + * @param {Function} callback - Function that receives a document fragment and populates it + * @param {jQuery} $container - Container to append the fragment to + * @returns {void} + */ +export function batchDomUpdates(callback, $container) { + if (!$container || !$container.length) return; + + const fragment = document.createDocumentFragment(); + callback(fragment); + $container[0].appendChild(fragment); +} diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js index fb66c63..b0b2398 100644 --- a/src/systems/rendering/thoughts.js +++ b/src/systems/rendering/thoughts.js @@ -23,6 +23,7 @@ import { import { isItemLocked, setItemLock } from '../generation/lockManager.js'; import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js'; import { queueThoughtBasedExpressionsUpdate } from '../integration/thoughtBasedExpressions.js'; +import { updateIfChanged } from './renderUtils.js'; /** * Helper to generate lock icon HTML if setting is enabled @@ -477,13 +478,15 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback = html += ''; } - $thoughtsContainer.html(html); + const domUpdated = updateIfChanged($thoughtsContainer, html, 'rpg-thoughts'); - debugLog('[RPG Thoughts] ✓ HTML rendered to container'); + debugLog('[RPG Thoughts] ✓ HTML rendered to container, updated:', domUpdated); debugLog('[RPG Thoughts] ======================================================='); - // Add event handlers for editable character fields - $thoughtsContainer.find('.rpg-editable').on('blur', function () { + // Only re-bind event handlers if DOM was actually updated + if (domUpdated) { + // Add event handlers for editable character fields + $thoughtsContainer.find('.rpg-editable').on('blur', function () { const character = $(this).data('character'); const field = $(this).data('field'); const value = $(this).text().trim(); @@ -598,6 +601,7 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback = } } }); + } // end if (domUpdated) // Remove updating class after animation if (extensionSettings.enableAnimations) { diff --git a/src/systems/rendering/userStats.js b/src/systems/rendering/userStats.js index 909a8f7..6e311b2 100644 --- a/src/systems/rendering/userStats.js +++ b/src/systems/rendering/userStats.js @@ -5,25 +5,16 @@ import { getContext } from '../../../../../../extensions.js'; import { user_avatar } from '../../../../../../../script.js'; -import { - extensionSettings, - lastGeneratedData, - committedTrackerData, - $userStatsContainer, - FALLBACK_AVATAR_DATA_URI -} from '../../core/state.js'; +import { extensionSettings, lastGeneratedData, committedTrackerData, $userStatsContainer, FALLBACK_AVATAR_DATA_URI } from '../../core/state.js'; import { i18n } from '../../core/i18n.js'; -import { - saveSettings, - saveChatData, - updateMessageSwipeData -} from '../../core/persistence.js'; +import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { buildInventorySummary } from '../generation/promptBuilder.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js'; import { updateFabWidgets } from '../ui/mobile.js'; import { getStatBarColors } from '../ui/theme.js'; import { getEquipmentBonuses } from '../interaction/equipmentActions.js'; +import { updateIfChanged } from './renderUtils.js'; /** * Extracts the base name (before parentheses) and converts to snake_case for use as JSON key. @@ -449,10 +440,12 @@ export function renderUserStats() { // console.log('[RPG UserStats Render] Container exists:', !!$userStatsContainer, '$userStatsContainer length:', $userStatsContainer?.length); // Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM) - $userStatsContainer.html(html); - // console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container'); + // Use change detection to skip unnecessary DOM updates + const domUpdated = updateIfChanged($userStatsContainer, html, 'rpg-user-stats'); + // console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container, updated:', domUpdated); - // Add event listeners for editable stat values + // Only re-bind event listeners if DOM was actually updated + if (domUpdated) { $('.rpg-editable-stat').on('blur', function () { const field = $(this).data('field'); const mode = $(this).data('mode'); @@ -609,4 +602,5 @@ export function renderUserStats() { // Save settings saveSettings(); }); + } // end if (domUpdated) } diff --git a/style.css b/style.css index 3dad639..8bad2af 100644 --- a/style.css +++ b/style.css @@ -6881,6 +6881,8 @@ body:has(.rpg-panel.rpg-mobile-open) .rpg-fab-widget-container { gap: 1rem; padding: 0.5rem; font-size: 0.9rem; + content-visibility: auto; + contain-intrinsic-size: 0 500px; } /* Sub-tabs Navigation */ @@ -12079,6 +12081,8 @@ body.documentstyle .rpg-inline-thoughts { .rpg-equipment-container { padding: 8px 0; + content-visibility: auto; + contain-intrinsic-size: 0 300px; } .rpg-equipment-header {