From 9cc9ef86c0d1a044789f803b61678e5a28d9f251 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:58:27 +0200 Subject: [PATCH 1/3] fix(inventory): stop unseen-tracker mutating shared state arrays in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resetItems/removeUnseen/the UnseenItemsEvent handler each did `new Map(prevValue)` (a shallow copy) then spliced/pushed the per-category array returned by `.get(category)` — the SAME array reference still held by the previous Map. That mutates state outside React's data flow (breaks under StrictMode double-invoke and any updater replay). resetItems additionally did `splice(existing.indexOf(id), 1)` with no guard, so an id not present (indexOf === -1) spliced off the wrong LAST element. Replace each in-place splice/push with a cloned array set back on the new Map (filter for removals, spread+push for the merge). --- .../inventory/useInventoryUnseenTracker.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/hooks/inventory/useInventoryUnseenTracker.ts b/src/hooks/inventory/useInventoryUnseenTracker.ts index 9a22fb0..baace9b 100644 --- a/src/hooks/inventory/useInventoryUnseenTracker.ts +++ b/src/hooks/inventory/useInventoryUnseenTracker.ts @@ -63,7 +63,10 @@ const useInventoryUnseenTrackerState = () => const newValue = new Map(prevValue); const existing = newValue.get(category); - if(existing) for(const itemId of itemIds) existing.splice(existing.indexOf(itemId), 1); + // Replace the per-category array instead of splicing the one still + // referenced by the previous Map, and filter (an absent id used to + // splice(indexOf=-1) and drop the wrong last element). + if(existing) newValue.set(category, existing.filter(id => !itemIds.includes(id))); sendResetItemsMessage(category, itemIds); @@ -90,9 +93,9 @@ const useInventoryUnseenTrackerState = () => const newValue = new Map(prevValue); const items = newValue.get(category); - const index = items.indexOf(itemId); - if(index >= 0) items.splice(index, 1); + // Clone the array rather than splicing the one shared with prevValue. + if(items && items.indexOf(itemId) >= 0) newValue.set(category, items.filter(id => id !== itemId)); return newValue; }); @@ -108,18 +111,15 @@ const useInventoryUnseenTrackerState = () => for(const category of parser.categories) { - let existing = newValue.get(category); - - if(!existing) - { - existing = []; - - newValue.set(category, existing); - } + // Clone the existing array so we never push into the one still + // referenced by the previous (shallow-copied) Map. + const merged = [ ...(newValue.get(category) ?? []) ]; const itemIds = parser.getItemsByCategory(category); - for(const itemId of itemIds) ((existing.indexOf(itemId) === -1) && existing.push(itemId)); + for(const itemId of itemIds) if(merged.indexOf(itemId) === -1) merged.push(itemId); + + newValue.set(category, merged); } return newValue; From 39fbfdd9e2db880acd60f99e290205dd3286567e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 15:58:27 +0200 Subject: [PATCH 2/3] fix(inventory): derive active prefix from the fresh list, not a stale closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ActivePrefixUpdatedEvent handler set the active prefix via `setActivePrefix(prev => { const found = prefixes.find(...) })`, reading the `prefixes` state from the closure — which lags by a render and is stale/empty when the prefix was added earlier in the same event batch, so `found` was undefined and the active prefix fell back to a partial item missing icon/color/displayName. Move the derivation inside the `setPrefixes` updater so it reads the freshly-mapped list. --- src/hooks/inventory/useInventoryPrefixes.ts | 32 ++++++++++++--------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/hooks/inventory/useInventoryPrefixes.ts b/src/hooks/inventory/useInventoryPrefixes.ts index 81702d3..d42126d 100644 --- a/src/hooks/inventory/useInventoryPrefixes.ts +++ b/src/hooks/inventory/useInventoryPrefixes.ts @@ -78,25 +78,29 @@ const useInventoryPrefixesState = () => setPrefixes(prevValue => { - return prevValue.map(p => ({ + const next = prevValue.map(p => ({ ...p, active: p.id === parser.prefixId })); - }); - if(parser.prefixId === 0) - { - setActivePrefix(null); - } - else - { - setActivePrefix(prev => + // Derive the active prefix from the freshly-mapped list, not from + // the `prefixes` closure (which lags a render and is stale when the + // prefix was added earlier in the same event batch). + if(parser.prefixId === 0) { - const found = prefixes.find(p => p.id === parser.prefixId); - if(found) return { ...found, active: true, font: parser.font || found.font || '' }; - return { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', font: parser.font || '', active: true }; - }); - } + setActivePrefix(null); + } + else + { + const found = next.find(p => p.id === parser.prefixId); + + setActivePrefix(found + ? { ...found, active: true, font: parser.font || found.font || '' } + : { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', font: parser.font || '', active: true }); + } + + return next; + }); }); const activatePrefix = (prefixId: number) => From af6f65b1940240805b3d9159eacf5ef4159b0405 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 13 Jun 2026 16:04:07 +0200 Subject: [PATCH 3/3] fix(inventory): drop leaking badge pending-counter; trust server active set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendActiveBadges incremented pendingUpdatesRef on every edit, and the BadgesEvent handler skipped applying the server's active badges while the counter was > 0, decrementing once per BadgesEvent. The assumption was "one BadgesEvent echo per SetActivatedBadges" — but the emulator's UserWearBadgeEvent answers with a UserBadgesComposer room broadcast, NOT a BadgesEvent, so nothing ever decrements the counter. It leaks upward with every toggle/reorder/swap and then silently drops legitimate later BadgesEvent updates (the server-authoritative active set never reapplies). Remove the counter and always apply the server's active badges on BadgesEvent (edits are already persisted server-side, so this is correct). --- src/hooks/inventory/useInventoryBadges.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/hooks/inventory/useInventoryBadges.ts b/src/hooks/inventory/useInventoryBadges.ts index f456c1f..ae13058 100644 --- a/src/hooks/inventory/useInventoryBadges.ts +++ b/src/hooks/inventory/useInventoryBadges.ts @@ -1,5 +1,5 @@ import { BadgeReceivedEvent, BadgesEvent, RequestBadgesComposer, SetActivatedBadgesComposer } from '@nitrots/nitro-renderer'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useBetween } from 'use-between'; import { GetConfigurationValue, SendMessageComposer, UnseenItemCategory } from '../../api'; import { useMessageEvent } from '../events'; @@ -17,7 +17,6 @@ const useInventoryBadgesState = () => const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker(); const maxBadgeCount = GetConfigurationValue('user.badges.max.slots', 5); - const pendingUpdatesRef = useRef(0); const isWearingBadge = (badgeCode: string) => activeBadgeCodes.some(code => code === badgeCode); const canWearBadges = () => (activeBadgeCodes.filter(Boolean).length < maxBadgeCount); @@ -35,7 +34,6 @@ const useInventoryBadgesState = () => const sendActiveBadges = (badges: (string | null)[]) => { - pendingUpdatesRef.current++; const composer = new SetActivatedBadgesComposer(); for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? ''); SendMessageComposer(composer); @@ -93,16 +91,14 @@ const useInventoryBadgesState = () => return newValue; }); - // Skip overwriting activeBadgeCodes if we have pending local changes - if(pendingUpdatesRef.current > 0) - { - pendingUpdatesRef.current--; - } - else - { - const serverBadges = parser.getActiveBadgeCodes(); - setActiveBadgeCodes(toFixedSlots(serverBadges)); - } + // The emulator answers SetActivatedBadges (UserWearBadgeEvent) with a + // UserBadgesComposer room broadcast, NOT a BadgesEvent — so there is no + // echo to suppress and the old pendingUpdatesRef counter only ever + // leaked (incremented on every edit, never decremented), which then + // silently dropped legitimate later BadgesEvent updates. The server is + // authoritative here (edits are already persisted), so always apply it. + const serverBadges = parser.getActiveBadgeCodes(); + setActiveBadgeCodes(toFixedSlots(serverBadges)); setBadgeCodes(allBadgeCodes); });