Fixes #7: Rendering performance optimizations

- requestAnimationFrame batching: rerenderRpgState() now batches all
  render calls into a single animation frame, reducing reflow/repaint costs

- Lightweight change detection: Render functions use updateIfChanged() to
  skip DOM updates when content hasn't changed, and only re-bind event
  handlers when the DOM was actually updated

- CSS content-visibility: Added content-visibility: auto with
  contain-intrinsic-size to inventory and equipment containers, deferring
  rendering of off-screen content

New file: src/systems/rendering/renderUtils.js (rendering utilities module)
Updated: All render modules + sillytavern.js integration + style.css
This commit is contained in:
2026-07-12 13:10:17 +02:00
parent 1f8591d4ec
commit 24c964c38d
9 changed files with 239 additions and 40 deletions
+22
View File
@@ -53,6 +53,7 @@ import { renderInventory } from '../rendering/inventory.js';
import { renderEquipment } from '../rendering/equipment.js'; import { renderEquipment } from '../rendering/equipment.js';
import { renderQuests } from '../rendering/quests.js'; import { renderQuests } from '../rendering/quests.js';
import { renderMusicPlayer } from '../rendering/musicPlayer.js'; import { renderMusicPlayer } from '../rendering/musicPlayer.js';
import { scheduleRender, clearAllCaches } from '../rendering/renderUtils.js';
// Utils // Utils
import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js';
@@ -323,7 +324,28 @@ function restoreOrRepairLatestTrackerState() {
return restored; 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() { 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(); renderUserStats();
renderInfoBox(); renderInfoBox();
renderThoughts(); renderThoughts();
+2 -1
View File
@@ -6,6 +6,7 @@
import { extensionSettings, $equipmentContainer } from '../../core/state.js'; import { extensionSettings, $equipmentContainer } from '../../core/state.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { EQUIPMENT_CATEGORIES, SLOTS_LIST, escapeHtml } from '../equipment/constants.js'; import { EQUIPMENT_CATEGORIES, SLOTS_LIST, escapeHtml } from '../equipment/constants.js';
import { updateIfChanged } from './renderUtils.js';
/** /**
* Renders a single equipment slot * Renders a single equipment slot
@@ -150,7 +151,7 @@ export function renderEquipment() {
} }
const html = generateEquipmentHTML(); const html = generateEquipmentHTML();
$equipmentContainer.html(html); updateIfChanged($equipmentContainer, html, 'rpg-equipment');
// Re-apply translations // Re-apply translations
i18n.applyTranslations($equipmentContainer[0]); i18n.applyTranslations($equipmentContainer[0]);
+18 -14
View File
@@ -15,6 +15,7 @@ import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js'; import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js'; import { repairJSON } from '../../utils/jsonRepair.js';
import { updateFabWidgets } from '../ui/mobile.js'; import { updateFabWidgets } from '../ui/mobile.js';
import { updateIfChanged } from './renderUtils.js';
/** /**
* Helper to generate lock icon HTML if setting is enabled * Helper to generate lock icon HTML if setting is enabled
@@ -575,23 +576,25 @@ export function renderInfoBox() {
// Close the scrollable content wrapper // Close the scrollable content wrapper
html += '</div>'; html += '</div>';
$infoBoxContainer.html(html); const domUpdated = updateIfChanged($infoBoxContainer, html, 'rpg-info-box');
// Add dynamic text scaling for location field // Only re-bind event handlers and apply dynamic styling if DOM was updated
const updateLocationTextSize = ($element) => { if (domUpdated) {
const text = $element.text(); // Add dynamic text scaling for location field
const charCount = text.length; const updateLocationTextSize = ($element) => {
$element.css('--char-count', Math.min(charCount, 100)); const text = $element.text();
}; const charCount = text.length;
$element.css('--char-count', Math.min(charCount, 100));
};
// Initial size update for location // Initial size update for location
const $locationText = $infoBoxContainer.find('[data-field="location"]'); const $locationText = $infoBoxContainer.find('[data-field="location"]');
if ($locationText.length) { if ($locationText.length) {
updateLocationTextSize($locationText); updateLocationTextSize($locationText);
} }
// Add event handlers for editable Info Box fields // Add event handlers for editable Info Box fields
$infoBoxContainer.find('.rpg-editable').on('blur', function () { $infoBoxContainer.find('.rpg-editable').on('blur', function () {
const $this = $(this); const $this = $(this);
const field = $this.data('field'); const field = $this.data('field');
const value = $this.text().trim(); const value = $this.text().trim();
@@ -659,6 +662,7 @@ export function renderInfoBox() {
saveSettings(); saveSettings();
}); });
}); });
} // end if (domUpdated)
// Remove updating class after animation // Remove updating class after animation
if (extensionSettings.enableAnimations) { if (extensionSettings.enableAnimations) {
+7 -3
View File
@@ -10,6 +10,7 @@ import { updateInventoryItem } from '../interaction/inventoryEdit.js';
import { parseItems } from '../../utils/itemParser.js'; import { parseItems } from '../../utils/itemParser.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { updateIfChanged } from './renderUtils.js';
// Type imports // Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -594,13 +595,15 @@ export function renderInventory() {
// Generate HTML and update DOM // Generate HTML and update DOM
const html = generateInventoryHTML(inventory, options); const html = generateInventoryHTML(inventory, options);
$inventoryContainer.html(html); const domUpdated = updateIfChanged($inventoryContainer, html, 'rpg-inventory');
// Restore form states after re-rendering (fixes Bug #1) // Restore form states after re-rendering (fixes Bug #1)
restoreFormStates(); restoreFormStates();
// Event listener for editing item names (mobile-friendly contenteditable) // Only re-bind event handlers if DOM was actually updated
$inventoryContainer.find('.rpg-item-name.rpg-editable').on('blur', function() { 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 field = $(this).data('field');
const index = parseInt($(this).data('index')); const index = parseInt($(this).data('index'));
const location = $(this).data('location'); const location = $(this).data('location');
@@ -632,6 +635,7 @@ export function renderInventory() {
// Save settings // Save settings
saveSettings(); saveSettings();
}); });
} // end if (domUpdated)
} }
/** /**
+6 -3
View File
@@ -7,6 +7,7 @@ import { extensionSettings, $questsContainer, committedTrackerData, lastGenerate
import { saveSettings, saveChatData } from '../../core/persistence.js'; import { saveSettings, saveChatData } from '../../core/persistence.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { updateIfChanged } from './renderUtils.js';
/** /**
* Syncs the current extensionSettings.quests to committedTrackerData.userStats * Syncs the current extensionSettings.quests to committedTrackerData.userStats
@@ -247,10 +248,12 @@ export function renderQuests() {
} }
html += '</div></div>'; html += '</div></div>';
$questsContainer.html(html); const domUpdated = updateIfChanged($questsContainer, html, 'rpg-quests');
// Attach event handlers // Attach event handlers only if DOM was updated
attachQuestEventHandlers(); if (domUpdated) {
attachQuestEventHandlers();
}
} }
/** /**
+163
View File
@@ -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<string, string>}
*/
const htmlCache = new Map();
/**
* Pending render functions queued for the next animation frame.
* @type {Array<Function>}
*/
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);
}
+8 -4
View File
@@ -23,6 +23,7 @@ import {
import { isItemLocked, setItemLock } from '../generation/lockManager.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js'; import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js';
import { queueThoughtBasedExpressionsUpdate } from '../integration/thoughtBasedExpressions.js'; import { queueThoughtBasedExpressionsUpdate } from '../integration/thoughtBasedExpressions.js';
import { updateIfChanged } from './renderUtils.js';
/** /**
* Helper to generate lock icon HTML if setting is enabled * Helper to generate lock icon HTML if setting is enabled
@@ -477,13 +478,15 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
html += '</div>'; html += '</div>';
} }
$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] ======================================================='); debugLog('[RPG Thoughts] =======================================================');
// Add event handlers for editable character fields // Only re-bind event handlers if DOM was actually updated
$thoughtsContainer.find('.rpg-editable').on('blur', function () { if (domUpdated) {
// Add event handlers for editable character fields
$thoughtsContainer.find('.rpg-editable').on('blur', function () {
const character = $(this).data('character'); const character = $(this).data('character');
const field = $(this).data('field'); const field = $(this).data('field');
const value = $(this).text().trim(); const value = $(this).text().trim();
@@ -598,6 +601,7 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
} }
} }
}); });
} // end if (domUpdated)
// Remove updating class after animation // Remove updating class after animation
if (extensionSettings.enableAnimations) { if (extensionSettings.enableAnimations) {
+9 -15
View File
@@ -5,25 +5,16 @@
import { getContext } from '../../../../../../extensions.js'; import { getContext } from '../../../../../../extensions.js';
import { user_avatar } from '../../../../../../../script.js'; import { user_avatar } from '../../../../../../../script.js';
import { import { extensionSettings, lastGeneratedData, committedTrackerData, $userStatsContainer, FALLBACK_AVATAR_DATA_URI } from '../../core/state.js';
extensionSettings,
lastGeneratedData,
committedTrackerData,
$userStatsContainer,
FALLBACK_AVATAR_DATA_URI
} from '../../core/state.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
saveSettings,
saveChatData,
updateMessageSwipeData
} from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { buildInventorySummary } from '../generation/promptBuilder.js'; import { buildInventorySummary } from '../generation/promptBuilder.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { updateFabWidgets } from '../ui/mobile.js'; import { updateFabWidgets } from '../ui/mobile.js';
import { getStatBarColors } from '../ui/theme.js'; import { getStatBarColors } from '../ui/theme.js';
import { getEquipmentBonuses } from '../interaction/equipmentActions.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. * 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); // 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) // Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html(html); // Use change detection to skip unnecessary DOM updates
// console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container'); 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 () { $('.rpg-editable-stat').on('blur', function () {
const field = $(this).data('field'); const field = $(this).data('field');
const mode = $(this).data('mode'); const mode = $(this).data('mode');
@@ -609,4 +602,5 @@ export function renderUserStats() {
// Save settings // Save settings
saveSettings(); saveSettings();
}); });
} // end if (domUpdated)
} }
+4
View File
@@ -6881,6 +6881,8 @@ body:has(.rpg-panel.rpg-mobile-open) .rpg-fab-widget-container {
gap: 1rem; gap: 1rem;
padding: 0.5rem; padding: 0.5rem;
font-size: 0.9rem; font-size: 0.9rem;
content-visibility: auto;
contain-intrinsic-size: 0 500px;
} }
/* Sub-tabs Navigation */ /* Sub-tabs Navigation */
@@ -12079,6 +12081,8 @@ body.documentstyle .rpg-inline-thoughts {
.rpg-equipment-container { .rpg-equipment-container {
padding: 8px 0; padding: 8px 0;
content-visibility: auto;
contain-intrinsic-size: 0 300px;
} }
.rpg-equipment-header { .rpg-equipment-header {