From e8f418908f5a7e28a78386aabe734c1a6fa4f84f Mon Sep 17 00:00:00 2001 From: hermanest Date: Fri, 12 Jun 2026 18:56:00 +0300 Subject: [PATCH 1/9] Added a check to prevent multiple leaf calls in a single frame in Label. --- .../Reactive.BeatSaber/Components/Label.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/reactive-bs-sdk/Reactive.BeatSaber/Components/Label.cs b/src/reactive-bs-sdk/Reactive.BeatSaber/Components/Label.cs index dc04693..4b81aee 100644 --- a/src/reactive-bs-sdk/Reactive.BeatSaber/Components/Label.cs +++ b/src/reactive-bs-sdk/Reactive.BeatSaber/Components/Label.cs @@ -136,6 +136,7 @@ float ISkewedComponent.Skew { private TextMeshProUGUI _text = null!; private Vector2 _lastPreferredSize; + private float _lastRequestTime; protected override void Construct(RectTransform rect) { _text = rect.gameObject.AddComponent(); @@ -162,6 +163,14 @@ public Vector2 Measure(float width, MeasureMode widthMode, float height, Measure } private void RequestLeafRecalculationOnDirty() { + var time = Time.time; + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (time == _lastRequestTime) { + return; + } + + _lastRequestTime = time; + var size = _text.GetPreferredValues(); if (size == _lastPreferredSize) { From 34fec2ff06f62266e6fd0130d73b4075186bcb43 Mon Sep 17 00:00:00 2001 From: hermanest Date: Fri, 12 Jun 2026 18:56:28 +0300 Subject: [PATCH 2/9] Made ListCell check item equality to avoid redundant reinitializations. --- .../Reactive.Components/Components/ListView/ListCell.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/reactive-sdk/Reactive.Components/Components/ListView/ListCell.cs b/src/reactive-sdk/Reactive.Components/Components/ListView/ListCell.cs index 410b8fc..191dec0 100644 --- a/src/reactive-sdk/Reactive.Components/Components/ListView/ListCell.cs +++ b/src/reactive-sdk/Reactive.Components/Components/ListView/ListCell.cs @@ -1,4 +1,5 @@ -using JetBrains.Annotations; +using System.Collections.Generic; +using JetBrains.Annotations; using UnityEngine; namespace Reactive.Components; @@ -46,9 +47,11 @@ void IListCell.Init(TItem item) { if (!IsInitialized) { _observableItem = new(item); ConstructAndInit(); + } else if (EqualityComparer.Default.Equals(_observableItem!.Value, item)) { + return; } + _observableItem!.Value = item; - OnInit(item); } From ffe7446791a948eb20c0f80fb98acef1680dbd97 Mon Sep 17 00:00:00 2001 From: hermanest Date: Sat, 13 Jun 2026 11:33:51 +0300 Subject: [PATCH 3/9] Added checks to avoid loading an image for a single url twice. --- .../Extensions/GIF/ImageLoaderModule.cs | 17 ++++++++++++++--- .../Extensions/ImageExtensions.cs | 8 +------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoaderModule.cs b/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoaderModule.cs index fbbc2ea..0000921 100644 --- a/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoaderModule.cs +++ b/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoaderModule.cs @@ -11,6 +11,8 @@ public class ImageLoaderModule(ISpriteRenderer renderer) : IReactiveModule { public CachedImage? LoadedImage; private CancellationTokenSource _tokenSource = new(); + private Sprite? _initialSprite; + private string? _url; private Task? _loadTask; public void StopLoading() { @@ -23,15 +25,21 @@ public void StopLoading() { } public void LoadRemote(string url, Action? onStart, Action? onFinish) { + if (url == _url) { + return; + } + StopLoading(); LoadedImage = null; + _initialSprite = renderer.Sprite; + _url = url; _loadTask = LoadRemoteInternal(url, onStart, onFinish, _tokenSource.Token); } private async Task LoadRemoteInternal( string url, - Action? onStart, + Action? onStart, Action? onFinish, CancellationToken token ) { @@ -49,7 +57,10 @@ CancellationToken token return; } - renderer.Sprite = image.Sprite; + if (_initialSprite == renderer.Sprite) { + renderer.Sprite = image.Sprite; + } + LoadedImage = image; onFinish?.Invoke(true); @@ -57,7 +68,7 @@ CancellationToken token // do nothing } catch (Exception ex) { Debug.LogError($"Image loading has failed: {ex}"); - + onFinish?.Invoke(false); } } diff --git a/src/reactive-sdk/Reactive.Components/Extensions/ImageExtensions.cs b/src/reactive-sdk/Reactive.Components/Extensions/ImageExtensions.cs index 44bbbf7..2aa355c 100644 --- a/src/reactive-sdk/Reactive.Components/Extensions/ImageExtensions.cs +++ b/src/reactive-sdk/Reactive.Components/Extensions/ImageExtensions.cs @@ -25,12 +25,6 @@ public static IComponentHolder WithWebSource( if (ResolveModule(binder) is not { } loaderModule) { loaderModule = new(renderer); binder.BindModule(loaderModule); - - comp.Component.WithListener(x => x.Sprite, (newSprite) => { - if (newSprite != loaderModule.LoadedImage?.Sprite) { - loaderModule.StopLoading(); - } - }); } loaderModule.LoadRemote(url, onStart, onFinish); @@ -50,6 +44,6 @@ public static T CancelWebLoading(this T comp) where T : IComponentHolder x.GetType() == typeof(ImageLoaderModule)) as ImageLoaderModule; + return binder.Modules.OfType().FirstOrDefault(); } } \ No newline at end of file From 333b5055243ab34aa0a117f512e4f029e268cef6 Mon Sep 17 00:00:00 2001 From: hermanest Date: Sat, 13 Jun 2026 11:40:39 +0300 Subject: [PATCH 4/9] Optimized ImageLoader. Added semaphores to prevent races --- .../Extensions/GIF/ImageLoader.cs | 173 +++++++++++++----- 1 file changed, 124 insertions(+), 49 deletions(-) diff --git a/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs b/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs index 91abc1f..b269c8a 100644 --- a/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs +++ b/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs @@ -8,6 +8,7 @@ using B83.Image.GIF; using JetBrains.Annotations; using UnityEngine; +using UnityEngine.Networking; namespace Reactive.Components; @@ -18,81 +19,153 @@ public static class ImageLoader { private static readonly Dictionary images = new(); private static readonly HttpClient client = new(); + private static readonly Dictionary semaphores = new(); + private static readonly object semaphoresLock = new(); + /// - /// Loads an image from a remote url. + /// Loads an image from the provided location. Can be either a remote url, an assembly path or a file. /// - /// A url to fetch the data from. - /// A loaded image or null. + /// A location to load the data from. + /// A cancellation token. public static async Task LoadImage(string location, CancellationToken token) { + var semaphore = GetSemaphore(location); + await semaphore.WaitAsync(token); + if (images.TryGetValue(location, out var image)) { return image; } - var stream = await GetDataAsync(location, token); + try { + if (IsRemote(location)) { + if (IsPotentiallyAnimated(location)) { + image = await LoadAnyRemote(location, token); + } else { + // If the image isn't animated, use an optimized request version + // to load directly to a native texture, avoiding managed allocations + image = await LoadStaticRemote(location); + } + } else { + Stream? stream = null; + + if (TryGetAssembly(location, out var asm, out var asmPath)) { + // + stream = asm!.GetManifestResourceStream(asmPath); + // + } else if (File.Exists(location)) { + // + stream = File.OpenRead(location); + } - image = await LoadImage(stream, null, token); - - if (image != null) { - images[location] = image; + if (stream != null) { + try { + image = await LoadImageFromStream(stream, null, token); + } + finally { + stream.Dispose(); + } + } + } + + if (image != null) { + images[location] = image; + } + + return image; } + finally { + semaphore.Release(); + } + } - return image; + /// + /// Loads an image from the specified buffer. + /// + /// A buffer to load from. + /// A cancellation token. + public static async Task LoadImageFromBytes(byte[] bytes, CancellationToken token) { + using var stream = new MemoryStream(bytes); + + return await LoadImageFromStream(stream, bytes, token); + } + + public static void RemoveCached(string location) { + images.Remove(location); } - internal static async Task GetDataAsync(string location, CancellationToken token) { - if (location.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || location.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - var response = await client.GetAsync(location, token); - return await response.Content.ReadAsStreamAsync(); - } else if (File.Exists(location)) { - using (FileStream fileStream = File.OpenRead(location)) - using (MemoryStream memoryStream = new(new byte[fileStream.Length], true)) - { - await fileStream.CopyToAsync(memoryStream); - return memoryStream; + private static SemaphoreSlim GetSemaphore(string location) { + lock (semaphoresLock) { + if (!semaphores.TryGetValue(location, out var semaphore)) { + semaphore = new(1, 1); + semaphores[location] = semaphore; } - } else { - AssemblyFromPath(location, out Assembly asm, out string newPath); - return await GetResourceAsync(asm, newPath); + + return semaphore; } } - internal static void AssemblyFromPath(string inputPath, out Assembly assembly, out string path) { - string[] parameters = inputPath.Split(':'); + #region Remote + + private static bool IsRemote(string location) { + return location.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + location.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPotentiallyAnimated(string location) { + return location.EndsWith(".gif", StringComparison.OrdinalIgnoreCase); + } + + private static async Task LoadStaticRemote(string location) { + using var req = UnityWebRequestTexture.GetTexture(location); + + var source = new TaskCompletionSource(); + + req.SendWebRequest().completed += _ => { + // ReSharper disable once AccessToDisposedClosure + var tex = DownloadHandlerTexture.GetContent(req); + var sprite = SpriteUtils.CreateSprite(tex); + + var cached = sprite != null ? new CachedImage(sprite) : null; + + source.SetResult(cached); + }; + + return await source.Task; + } + + private static async Task LoadAnyRemote(string location, CancellationToken token) { + using var stream = await client.GetStreamAsync(location); + + return await LoadImageFromStream(stream, null, token); + } + + #endregion + + #region Assembly + + private static bool TryGetAssembly(string location, out Assembly? assembly, out string? path) { + var parameters = location.Split(':'); + switch (parameters.Length) { case 1: path = parameters[0]; assembly = Assembly.Load(path.Substring(0, path.IndexOf('.'))); - break; + return true; case 2: path = parameters[1]; assembly = Assembly.Load(parameters[0]); - break; + return true; default: - throw new Exception($"Could not process resource path {inputPath}"); + assembly = null; + path = null; + return false; } } - internal static async Task GetResourceAsync(Assembly asm, string resourceName) { - using Stream resourceStream = asm.GetManifestResourceStream(resourceName); - using MemoryStream memoryStream = new(new byte[resourceStream.Length], true); - - await resourceStream.CopyToAsync(memoryStream); + #endregion - return memoryStream; - } + #region Stream - /// - /// Loads an image using a byte array. - /// - /// An array to load from. - /// A loaded image or null. - public static Task LoadImage(byte[] data, CancellationToken token) { - using var stream = new MemoryStream(data); - - return LoadImage(stream, data, token); - } - - private static async Task LoadImage(Stream stream, byte[]? bytes, CancellationToken token) { + private static async Task LoadImageFromStream(Stream stream, byte[]? bytes, CancellationToken token) { // Try to load as GIF first if (await TryLoadGifImage(stream, token) is { } gif) { return new CachedImage(gif); @@ -110,7 +183,7 @@ internal static async Task GetResourceAsync(Assembly asm, string resourc return new CachedImage(sprite!); } catch (Exception ex) { - Debug.LogWarning($"Failed to create static image: {ex.Message}"); + Debug.LogWarning($"Failed to create a static image: {ex.Message}"); return null; } } @@ -121,11 +194,11 @@ internal static async Task GetResourceAsync(Assembly asm, string resourc try { // Important to leave open as it's just a wrapper var reader = new BinaryReader(stream); - + // Returns null if magic is invalid return new GIFLoader().Load(reader); } catch (Exception ex) { - Debug.LogError($"Failed to load GIF: {ex}"); + Debug.LogError($"Failed to create a GIF: {ex}"); return null; } @@ -150,4 +223,6 @@ private static async Task ReadStreamToBufferAsync(Stream stream, Cancell return buffer; } + + #endregion } \ No newline at end of file From 4ac73ed001885dc46f2a137600d561d63bd97246 Mon Sep 17 00:00:00 2001 From: hermanest Date: Sat, 13 Jun 2026 11:41:32 +0300 Subject: [PATCH 5/9] Oops --- .../Reactive.Components/Reactive.Components.csproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/reactive-sdk/Reactive.Components/Reactive.Components.csproj b/src/reactive-sdk/Reactive.Components/Reactive.Components.csproj index 8896e26..70839ba 100644 --- a/src/reactive-sdk/Reactive.Components/Reactive.Components.csproj +++ b/src/reactive-sdk/Reactive.Components/Reactive.Components.csproj @@ -45,6 +45,12 @@ $(UnityAssembliesDir)/UnityEngine.ImageConversionModule.dll False + + $(UnityAssembliesDir)/UnityEngine.UnityWebRequestTextureModule.dll + + + $(UnityAssembliesDir)/UnityEngine.UnityWebRequestModule.dll + From 4822b885e5f53345018b2e9f93378c6bbe3423ca Mon Sep 17 00:00:00 2001 From: hermanest Date: Sat, 13 Jun 2026 11:54:42 +0300 Subject: [PATCH 6/9] Fixed semaphore not being release when an image is cached in ImageLoader --- .../Extensions/GIF/ImageLoader.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs b/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs index b269c8a..21e07fa 100644 --- a/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs +++ b/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs @@ -31,11 +31,11 @@ public static class ImageLoader { var semaphore = GetSemaphore(location); await semaphore.WaitAsync(token); - if (images.TryGetValue(location, out var image)) { - return image; - } - try { + if (images.TryGetValue(location, out var image)) { + return image; + } + if (IsRemote(location)) { if (IsPotentiallyAnimated(location)) { image = await LoadAnyRemote(location, token); @@ -84,10 +84,10 @@ public static class ImageLoader { /// A cancellation token. public static async Task LoadImageFromBytes(byte[] bytes, CancellationToken token) { using var stream = new MemoryStream(bytes); - + return await LoadImageFromStream(stream, bytes, token); } - + public static void RemoveCached(string location) { images.Remove(location); } From e8ade02e3e3d6625747edc69a888363a83c9bd5d Mon Sep 17 00:00:00 2001 From: hermanest Date: Sat, 13 Jun 2026 11:58:24 +0300 Subject: [PATCH 7/9] Converted all cycles in ReactiveComponentHost to 'for' to reduce unnecessary allocations --- .../Reactive/ReactiveComponentHost.cs | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/src/reactive/Reactive/ReactiveComponentHost.cs b/src/reactive/Reactive/ReactiveComponentHost.cs index f09750e..e200a95 100644 --- a/src/reactive/Reactive/ReactiveComponentHost.cs +++ b/src/reactive/Reactive/ReactiveComponentHost.cs @@ -3,6 +3,7 @@ using System.Linq; using UnityEngine; +// ReSharper disable ForCanBeConvertedToForeach namespace Reactive { public partial class ReactiveComponent { [RequireComponent(typeof(RectTransform))] @@ -79,7 +80,9 @@ public RectTransform BeginApply() { public void EndApply() { _beingApplied = false; - _components.ForEach(static x => x.OnLayoutApply()); + for (var i = 0; i < _components.Count; i++) { + _components[i].OnLayoutApply(); + } } #endregion @@ -105,7 +108,9 @@ public void RecalculateLayoutImmediate() { LayoutDriver.RecalculateLayoutImmediate(); } else { // If not, tell own components to start recalculation (if there is a Layout or a custom layout controller) - _components.ForEach(static x => x.OnRecalculateLayoutSelf()); + for (var i = 0; i < _components.Count; i++) { + _components[i].OnRecalculateLayoutSelf(); + } } _beingRecalculated = false; @@ -275,13 +280,23 @@ private void Awake() { } private void Start() { - _components.ForEach(static x => x.OnStart()); + for (var i = 0; i < _components.Count; i++) { + _components[i].OnStart(); + } + IsStarted = true; } private void Update() { - _components.ForEach(static x => x.OnUpdate()); - _modules?.ForEach(static x => x.OnUpdate()); + for (var i = 0; i < _components.Count; i++) { + _components[i].OnUpdate(); + } + + if (_modules != null) { + foreach (var module in _modules) { + module.OnUpdate(); + } + } } private void LateUpdate() { @@ -290,11 +305,17 @@ private void LateUpdate() { _recalculationScheduled = false; } - _components.ForEach(static x => x.OnLateUpdate()); + + for (var i = 0; i < _components.Count; i++) { + _components[i].OnLateUpdate(); + } } private void OnDestroy() { - _components.ForEach(static x => x.DestroyInternal()); + for (var i = 0; i < _components.Count; i++) { + _components[i].OnDestroy(); + } + IsDestroyed = true; } @@ -306,7 +327,9 @@ private void OnEnable() { StateUpdatedEvent?.Invoke(this); ScheduleLayoutRecalculationAfterStateChange(true); - _components.ForEach(static x => x.OnEnable()); + for (var i = 0; i < _components.Count; i++) { + _components[i].OnEnable(); + } _wasActuallyDisabled = false; } @@ -318,7 +341,9 @@ private void OnDisable() { StateUpdatedEvent?.Invoke(this); ScheduleLayoutRecalculationAfterStateChange(false); - _components.ForEach(static x => x.OnDisable()); + for (var i = 0; i < _components.Count; i++) { + _components[i].OnDisable(); + } _wasActuallyDisabled = true; } @@ -331,7 +356,10 @@ private void OnRectTransformDimensionsChange() { if (!_beingApplied) { _recalculationScheduled = true; } - _components.ForEach(static x => x.OnRectDimensionsChanged()); + + for (var i = 0; i < _components.Count; i++) { + _components[i].OnRectDimensionsChanged(); + } } #endregion From a990c4ea2a340cae545cd1fdc7eabe242eaf9ef0 Mon Sep 17 00:00:00 2001 From: hermanest Date: Thu, 18 Jun 2026 13:12:51 +0300 Subject: [PATCH 8/9] Fixed Table trying to select items not found in the list --- .../Components/Table/Table.cs | 34 +++++++++++++++---- .../Models/Table/ITable.cs | 6 ++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/reactive-sdk/Reactive.Components/Components/Table/Table.cs b/src/reactive-sdk/Reactive.Components/Components/Table/Table.cs index 591acd9..5dcef48 100644 --- a/src/reactive-sdk/Reactive.Components/Components/Table/Table.cs +++ b/src/reactive-sdk/Reactive.Components/Components/Table/Table.cs @@ -187,11 +187,18 @@ public void ScrollTo(int idx, bool animated = true) { } public void Select(int idx) { - if (SelectionMode is SelectionMode.None) return; - // + if (SelectionMode is SelectionMode.None) { + return; + } + + if (idx < 0 || idx >= _filteredItems.Count) { + throw new IndexOutOfRangeException(); + } + if (SelectionMode is SelectionMode.Single && _selectedIndexes.Count > 0) { _selectedIndexes.Clear(); } + _selectedIndexes.Add(idx); ForceRefreshVisibleCells(); NotifyPropertyChanged(nameof(SelectedIndexes)); @@ -207,19 +214,34 @@ public void ClearSelection(int idx = -1) { NotifyPropertyChanged(nameof(SelectedIndexes)); } - public void ScrollTo(TItem item, bool animated = true) { + public bool ScrollTo(TItem item, bool animated = true) { var index = FindIndex(item); + if (index == -1) { + return false; + } + ScrollTo(index, animated); + return true; } - public void Select(TItem item) { + public bool Select(TItem item) { var index = FindIndex(item); + if (index == -1) { + return false; + } + Select(index); + return true; } - public void ClearSelection(TItem item) { + public bool ClearSelection(TItem item) { var index = FindIndex(item); + if (index == -1) { + return false; + } + ClearSelection(index); + return true; } private int FindIndex(TItem item) { @@ -302,7 +324,7 @@ private void RefreshVisibleCells(float pos) { OnCellConstruct(cell); WhenCellConstructed?.Invoke(cell); - + //updating state if (_selectionRefreshNeeded) { var selected = _selectedIndexes.Contains(i); diff --git a/src/reactive-sdk/Reactive.Components/Models/Table/ITable.cs b/src/reactive-sdk/Reactive.Components/Models/Table/ITable.cs index 7d29447..c5e6e11 100644 --- a/src/reactive-sdk/Reactive.Components/Models/Table/ITable.cs +++ b/src/reactive-sdk/Reactive.Components/Models/Table/ITable.cs @@ -25,11 +25,11 @@ public interface ITable { public interface ITable : ITable { IReadOnlyList Items { get; } - void ScrollTo(TItem item, bool animated = true); + bool ScrollTo(TItem item, bool animated = true); - void Select(TItem item); + bool Select(TItem item); - void ClearSelection(TItem item); + bool ClearSelection(TItem item); } /// From 24582631f1bf87413bcc2edc4a910652bfd801e7 Mon Sep 17 00:00:00 2001 From: hermanest Date: Fri, 19 Jun 2026 16:26:04 +0300 Subject: [PATCH 9/9] Fixed gif check not working with URIs containing query params --- .../Extensions/GIF/ImageLoader.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs b/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs index 21e07fa..4329fc3 100644 --- a/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs +++ b/src/reactive-sdk/Reactive.Components/Extensions/GIF/ImageLoader.cs @@ -105,13 +105,28 @@ private static SemaphoreSlim GetSemaphore(string location) { #region Remote + private static readonly char[] _queryOrFragmentChars = ['?', '#']; + private static bool IsRemote(string location) { return location.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || location.StartsWith("https://", StringComparison.OrdinalIgnoreCase); } - + private static bool IsPotentiallyAnimated(string location) { - return location.EndsWith(".gif", StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(location)) { + return false; + } + + var endIndex = location.IndexOfAny(_queryOrFragmentChars); + if (endIndex == -1) { + endIndex = location.Length; + } + + if (endIndex < 4) { + return false; + } + + return string.Compare(location, endIndex - 4, ".gif", 0, 4, StringComparison.OrdinalIgnoreCase) == 0; } private static async Task LoadStaticRemote(string location) {