This skill should be used when adding features that update actors or items, implementing hook handlers, modifying update logic, or replacing embedded documents. Covers ownership guards, no-op checks, batched updates, queueUpdate wrapper, atomic document operations, render suppression with { render: false }, idempotency guards, and choosing between optimistic vs locking UI patterns.
Prevents update storms and render cascades in multi-client Foundry VTT sessions. Use when implementing document updates, hook handlers, or replacing embedded documents. Covers ownership guards, no-op checks, batched updates, queueUpdate wrapper, atomic operations, and render suppression.
/plugin marketplace add ImproperSubset/hh-agentics/plugin install fvtt-dev@hh-agenticsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Ensure document updates in Foundry VTT modules don't cause multi-client update storms or render cascades.
Invoke this skill when implementing ANY of the following in a Foundry VTT module:
Foundry VTT runs in multi-client sessions where hooks fire on ALL connected clients. Without proper guards:
Before any document update, ask: "Should this run on every client?"
// ❌ BAD: Runs on every connected client
Hooks.on("deleteItem", (item, options, userId) => {
item.parent.update({ "system.someField": newValue });
});
// ✅ GOOD: Only owner/GM performs the update
Hooks.on("deleteItem", (item, options, userId) => {
if (!item.parent?.isOwner) return;
item.parent.update({ "system.someField": newValue });
});
Common ownership checks:
item.isOwner - Current user owns this itemitem.parent?.isOwner - Current user owns the parent (actor/container)actor.isOwner - Current user owns this actorgame.user.isGM - Current user is the GMUse GM-only guards for:
Before calling update, check if the value actually changes:
// ❌ BAD: Always updates, even if value unchanged
await actor.update({ "system.selected_load_level": newLevel });
// ✅ GOOD: Skip if already set
if (actor.system.selected_load_level === newLevel) return;
await actor.update({ "system.selected_load_level": newLevel });
For flag-based updates:
// ✅ Skip if flag already matches target state
const currentProgress = actor.getFlag('bitd-alternate-sheets', 'abilityProgress') || {};
if (currentProgress[abilityId] === targetValue) return;
await actor.setFlag('bitd-alternate-sheets', 'abilityProgress', {
...currentProgress,
[abilityId]: targetValue
});
Combine multiple field changes into a single update call:
// ❌ BAD: Three separate updates (3x hooks, 3x database writes)
await actor.update({ "system.harm.level1.value": "Bruised" });
await actor.update({ "system.stress.value": 5 });
await actor.update({ "system.xp.value": 3 });
// ✅ GOOD: Single batched update
await actor.update({
"system.harm.level1.value": "Bruised",
"system.stress.value": 5,
"system.xp.value": 3
});
Wrap ALL document updates in queueUpdate to prevent concurrent update collisions:
import { queueUpdate } from "./update-queue.js";
// ✅ Prevents race conditions in multi-client sessions
await queueUpdate(async () => {
await this.actor.update(updates);
});
What queueUpdate does:
When to use:
When replacing embedded documents (items, effects), NEVER use delete+create:
// ❌ BAD: Delete + Create causes UI flicker and race conditions
await actor.deleteEmbeddedDocuments("Item", [oldItemId]);
await actor.createEmbeddedDocuments("Item", [newItemData]);
// UI renders "empty state" between these calls!
// ✅ GOOD: Update in place (atomic operation)
await actor.updateEmbeddedDocuments("Item", [{
_id: oldItemId,
name: newItemData.name,
img: newItemData.img,
system: newItemData.system
}]);
Use cases:
Only rerender sheets that are owned and currently visible:
// ❌ BAD: Rerenders ALL character sheets (including closed/unowned)
Hooks.on("renderBladesClockSheet", (sheet, html, data) => {
game.actors.forEach(actor => {
actor.sheet.render(false);
});
});
// ✅ GOOD: Only rerender owned, open sheets
Hooks.on("renderBladesClockSheet", (sheet, html, data) => {
game.actors.forEach(actor => {
if (actor.isOwner && actor.sheet.rendered) {
actor.sheet.render(false);
}
});
});
Understanding Foundry's render flow:
When document.update() is called, Foundry:
updateActor/updateItem hooks on each clientrender() on sheets registered in doc.appsThe { render: false } option suppresses only step 4 - hooks still fire, but automatic sheet re-renders are skipped. This pattern is used by the official dnd5e system.
When using optimistic UI, suppress the automatic re-render:
// Pattern: Optimistic update + suppress re-render
function optimisticClockUpdate(clockEl, doc, newValue) {
// 1. Update DOM immediately (optimistic)
clockEl.style.backgroundImage = `url('clocks/clock_${newValue}.svg')`;
// 2. Persist with render suppressed - DOM already shows correct state
await doc.update({ "system.value": newValue }, { render: false });
}
Suppress render when:
Allow render when:
// DON'T suppress render - sheet needs fresh data for ability list
// Removing an ability slot changes what abilities are shown
await actor.setFlag(MODULE_ID, "addedAbilitySlots", filteredSlots);
// Let Foundry re-render to rebuild the ability list from new data
Combine all guards into a single helper:
/**
* Safely updates a document with ownership, no-op, and render-suppression guards.
*/
export async function safeUpdate(doc, updateData, options = {}) {
// 1. Ownership guard - only owner should update
if (!doc?.isOwner) return false;
// 2. Empty update guard
const entries = Object.entries(updateData || {});
if (entries.length === 0) return false;
// 3. No-op detection - skip if values unchanged
const hasChange = entries.some(([key, value]) => {
// Objects always treated as changes (too complex to deep-compare)
if (value !== null && typeof value === "object") return true;
const currentValue = foundry.utils.getProperty(doc, key);
return currentValue !== value;
});
if (!hasChange) return false;
// 4. Queued, render-suppressed update
await queueUpdate(async () => {
await doc.update(updateData, { render: false, ...options });
});
return true;
}
Usage:
// Simple: handles all guards automatically
clockEl.style.backgroundImage = `url('clocks/clock_${newValue}.svg')`;
await safeUpdate(doc, { "system.value": newValue });
// Override render suppression if needed
await safeUpdate(doc, { "system.abilitySlots": newSlots }, { render: true });
For handlers that run frequently (keyup, mousemove), add debouncing:
import { debounce } from "./utils.js";
// ❌ BAD: Updates on every keystroke
html.find("input").on("keyup", async (ev) => {
await actor.update({ "system.notes": ev.target.value });
});
// ✅ GOOD: Debounce to reduce update frequency
html.find("input").on("keyup", debounce(async (ev) => {
await queueUpdate(async () => {
await actor.update({ "system.notes": ev.target.value });
});
}, 300));
When using checkboxes with <label> elements, CSS interactions can cause duplicate click events:
html.find(".item-checkbox").on("click", async (ev) => {
const itemBlock = ev.currentTarget.closest(".item-block");
const desiredState = !isCurrentlyEquipped;
// IDEMPOTENCY GUARD: Prevent ghost clicks from CSS label overlaps
// Store pending state on DOM element itself
if (itemBlock.dataset.optimisticState === String(desiredState)) {
return; // Already processing this state change
}
itemBlock.dataset.optimisticState = String(desiredState);
// Proceed with optimistic update
requestAnimationFrame(() => {
checkbox.checked = desiredState;
});
await safeUpdate(doc, { "system.equipped": desiredState });
});
When to use idempotency guards:
Not all UI interactions should use pure optimistic updates. Choose the pattern based on operation complexity.
Use for simple flag or field updates where the final visual state is known immediately:
// Pure optimistic: clock increment
clockEl.style.backgroundImage = `url('clocks/clock_${newValue}.svg')`;
await safeUpdate(doc, { "system.value": newValue });
Use for operations that create/delete embedded documents or have cascading effects:
// Locking: ability toggle that may create/delete items
html.find(".ability-checkbox").change(async (ev) => {
const checkboxList = abilityBlock.querySelectorAll(".ability-checkbox");
// 1. LOCK: Disable inputs during operation
checkboxList.forEach(el => el.setAttribute("disabled", "disabled"));
try {
// 2. Perform complex operations (may create/delete items)
if (!hadProgress && willHaveProgress) {
await createOwnedAbility(actor, abilityId);
} else if (hadProgress && !willHaveProgress) {
await deleteOwnedAbility(actor, abilityId);
}
// 3. Update DOM AFTER operations complete (not before)
abilityBlock.dataset.abilityProgress = String(targetProgress);
checkboxList.forEach(el => {
el.checked = slotIndex <= targetProgress;
});
} finally {
// 4. UNLOCK: Re-enable inputs
checkboxList.forEach(el => el.removeAttribute("disabled"));
}
});
| Aspect | Pure Optimistic | Locking Pattern |
|---|---|---|
| UI update timing | Before persist | After persist |
| User feedback | Immediate visual change | Disabled state during operation |
| Use case | Simple field updates | Complex multi-document operations |
| Failure handling | UI may show incorrect state | UI reflects actual final state |
Use Pure Optimistic when:
Use Locking when:
Before submitting any code that updates documents, verify:
if (!item.parent?.isOwner) return; or if (!game.user.isGM) return; where appropriatequeueUpdate(async () => { ... })updateEmbeddedDocuments() instead of delete+create for replacements{ render: false } with optimistic UI updateshtml.find(".toggle-something").on("click", async (ev) => {
const currentValue = this.actor.system.someFlag;
const newValue = !currentValue;
// Skip if unchanged
if (currentValue === newValue) return;
await queueUpdate(async () => {
await this.actor.update({ "system.someFlag": newValue });
});
// No manual render - hook handles it
});
Hooks.on("deleteItem", (item, options, userId) => {
// Guard: Only owner performs side effects
if (!item.parent?.isOwner) return;
// Check if update needed
const needsUpdate = /* your logic */;
if (!needsUpdate) return;
// Perform update
queueUpdate(async () => {
await item.parent.update({ /* changes */ });
});
});
async replaceAbility(oldAbilityId, newAbilityData) {
const oldAbility = this.actor.items.get(oldAbilityId);
if (!oldAbility) return;
// Update in place (atomic)
await queueUpdate(async () => {
await this.actor.updateEmbeddedDocuments("Item", [{
_id: oldAbilityId,
name: newAbilityData.name,
img: newAbilityData.img,
system: newAbilityData.system
}]);
});
}
// Every client updates, causing N × clients database writes
Hooks.on("deleteItem", (item) => {
item.parent.update({ ... }); // Missing ownership guard!
});
// Rerenders ALL sheets, including unowned/closed
Hooks.on("updateActor", (actor) => {
game.actors.forEach(a => a.sheet.render(false));
});
// UI flickers; race condition between delete and create
await actor.deleteEmbeddedDocuments("Item", [id]);
await actor.createEmbeddedDocuments("Item", [data]);
// Updates even if value unchanged (wasted database writes)
await actor.update({ "system.xp": actor.system.xp });
After implementing updates, test with multiple clients:
render{ render: false } pattern extensively in migrationsupdateEmbeddedDocuments vs delete+createImplementation notes:
queueUpdate and safeUpdate helpers typically live in a utils moduleLast Updated: 2026-01-05
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.