< Summary

Line coverage
94%
Covered lines: 193
Uncovered lines: 11
Coverable lines: 204
Total lines: 536
Line coverage: 94.6%
Branch coverage
93%
Covered branches: 114
Total branches: 122
Branch coverage: 93.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/allyaria/allyaria/src/Allyaria.Theming.Blazor/AryThemeProvider.razor

#LineLine coverage
 31<div @ref="_host" hidden aria-hidden="true"></div>
 32<style id="ary-theme-style">@GlobalCss</style>
 33
 34<CascadingValue Name="EffectiveThemeType" Value="@_effectiveType">
 35    @ChildContent
 36</CascadingValue>

/home/runner/work/allyaria/allyaria/src/Allyaria.Theming.Blazor/AryThemeProvider.razor.cs

#LineLine coverage
 1namespace Allyaria.Theming.Blazor;
 2
 3/// <summary>
 4/// Provides a Blazor component that connects the <see cref="IThemingService" /> to the browser, handling system theme
 5/// detection, persisted theme preference, and document direction/language.
 6/// </summary>
 7/// <remarks>
 8///     <para>
 9///     This component is intended to be placed near the root of the app and wraps the rest of the UI via
 10///     <see cref="ChildContent" />. It:
 11///     </para>
 12///     <list type="bullet">
 13///         <item>
 14///             <description>
 15///             Synchronizes the effective theme with OS/browser preferences when <see cref="ThemeType.System" /> is
 16///             selected.
 17///             </description>
 18///         </item>
 19///         <item>
 20///             <description>Persists the stored theme type in browser storage.</description>
 21///         </item>
 22///         <item>
 23///             <description>
 24///             Updates <c>dir</c> and <c>lang</c> attributes on <c>document.documentElement</c>, and toggles a <c>rtl</
 25///             class on <c>body</c>.
 26///             </description>
 27///         </item>
 28///     </list>
 29/// </remarks>
 30public sealed partial class AryThemeProvider : ComponentBase, IAsyncDisposable
 31{
 32    /// <summary>Local storage key used to persist the user's theme preference.</summary>
 33    private const string StorageKey = "allyaria.themeType";
 34
 35    /// <summary>
 36    /// Cancellation token source used to cancel ongoing asynchronous operations when the component is disposed.
 37    /// </summary>
 38    private CancellationTokenSource? _cts;
 39
 40    /// <summary>DotNet object reference used by JavaScript interop to call back into this component.</summary>
 41    private DotNetObjectReference<AryThemeProvider>? _dotNetRef;
 42
 43    /// <summary>Tracks the current effective theme type applied by this provider.</summary>
 44    /// <remarks>
 45    /// This value mirrors <see cref="IThemingService.EffectiveType" />, but is cached locally to avoid redundant update
 46    /// </remarks>
 47    private ThemeType _effectiveType;
 48
 49    /// <summary>Host element reference for this component, used as the root for JS interop initialization.</summary>
 50    private ElementReference _host;
 51
 52    /// <summary>Indicates whether system theme detection is currently initialized and listening for changes.</summary>
 53    private bool _isStarted;
 54
 55    /// <summary>
 56    /// Tracks the previously applied culture in order to detect when the effective UI culture changes. Used to avoid re
 57    /// direction and language updates to the document root.
 58    /// </summary>
 59    private CultureInfo? _lastCulture;
 60
 61    /// <summary>JavaScript module reference for the co-located <c>AryThemeProvider.razor.js</c> file.</summary>
 62    private IJSObjectReference? _module;
 63
 64    /// <summary>Cached copy of the stored theme type used for persistence and detection lifecycle management.</summary>
 65    private ThemeType _storedType = ThemeType.System;
 66
 67    /// <summary>Gets or sets the child content that will be rendered within this theme provider.</summary>
 68    [Parameter]
 69    [EditorRequired]
 670    public RenderFragment? ChildContent { get; set; }
 71
 72    /// <summary>Gets or sets the culture used to determine document direction and language attributes.</summary>
 73    /// <remarks>When not supplied, <see cref="CultureInfo.CurrentUICulture" /> is used as a fallback.</remarks>
 74    [CascadingParameter]
 2175    public CultureInfo? Culture { get; set; }
 76
 77    /// <summary>Gets the document-level CSS for the current effective theme.</summary>
 78    /// <remarks>
 79    /// This value is intended for consumption by the Razor markup to inject global CSS (for example, via a co-located &
 80    /// style&gt; block managed by this component).
 81    /// </remarks>
 382    private string GlobalCss => ThemingService.GetDocumentCss();
 83
 84    /// <summary>Gets the JavaScript runtime used for interop with the browser environment.</summary>
 85    [Inject]
 7486    public required IJSRuntime JsRuntime { get; init; }
 87
 88    /// <summary>Gets the theming service that maintains the current stored and effective theme types.</summary>
 89    [Inject]
 8990    public required IThemingService ThemingService { get; init; }
 91
 92    /// <summary>Detects the current system theme using the co-located JavaScript module.</summary>
 93    /// <param name="cancellationToken">A token used to cancel the detection operation.</param>
 94    /// <returns>
 95    /// A <see cref="ThemeType" /> corresponding to the detected system theme, or <c>null</c> when detection fails or th
 96    /// result is <see cref="ThemeType.System" />.
 97    /// </returns>
 98    public async Task<ThemeType?> DetectThemeAsync(CancellationToken cancellationToken = default)
 99    {
 9100        if (_module is null || IsCancellationRequested())
 101        {
 3102            return null;
 103        }
 104
 105        try
 106        {
 6107            var raw = await _module
 6108                .InvokeAsync<string>(identifier: "detect", cancellationToken: cancellationToken)
 6109                .ConfigureAwait(continueOnCapturedContext: false);
 110
 5111            var parsed = ParseThemeType(value: raw);
 112
 5113            return parsed is ThemeType.System
 5114                ? null
 5115                : parsed;
 116        }
 1117        catch
 118        {
 119            // On any JS interop failure, fall back to "no detection".
 1120            return null;
 121        }
 9122    }
 123
 124    /// <summary>
 125    /// Asynchronously disposes the component, cancelling outstanding work and releasing JS interop resources.
 126    /// </summary>
 127    /// <returns>A task representing the asynchronous dispose operation.</returns>
 128    public async ValueTask DisposeAsync()
 129    {
 130        try
 131        {
 4132            ThemingService.ThemeChanged -= OnThemeChangedAsync;
 133
 4134            if (_cts is not null)
 135            {
 3136                await _cts.CancelAsync().ConfigureAwait(continueOnCapturedContext: false);
 3137                _cts.Dispose();
 3138                _cts = null;
 139            }
 140
 4141            if (_module is not null)
 142            {
 143                try
 144                {
 3145                    await _module.InvokeVoidAsync(identifier: "dispose", _host)
 3146                        .ConfigureAwait(continueOnCapturedContext: false);
 3147                }
 0148                catch
 149                {
 150                    // Ignore teardown errors; they can race with navigation/unload.
 0151                }
 152
 3153                await _module.DisposeAsync().ConfigureAwait(continueOnCapturedContext: false);
 3154                _module = null;
 155            }
 156
 4157            _dotNetRef?.Dispose();
 4158            _dotNetRef = null;
 4159        }
 0160        catch
 161        {
 162            // Swallow dispose-time failures to avoid surfacing teardown exceptions to the host.
 0163        }
 4164    }
 165
 166    /// <summary>Attempts to load a previously stored theme type from the browser using the JS module.</summary>
 167    /// <param name="cancellationToken">A token used to cancel the retrieval operation.</param>
 168    /// <returns>
 169    /// The stored <see cref="ThemeType" /> if available and valid; otherwise <see cref="ThemeType.System" />.
 170    /// </returns>
 171    private async Task<ThemeType> GetStoredTypeAsync(CancellationToken cancellationToken = default)
 172    {
 7173        if (_module is null || IsCancellationRequested())
 174        {
 1175            return ThemeType.System;
 176        }
 177
 178        try
 179        {
 6180            var raw = await _module
 6181                .InvokeAsync<string?>(
 6182                    identifier: "getStoredTheme",
 6183                    cancellationToken: cancellationToken,
 6184                    StorageKey
 6185                )
 6186                .ConfigureAwait(continueOnCapturedContext: false);
 187
 4188            return Enum.TryParse(value: raw, ignoreCase: true, result: out ThemeType parsed)
 4189                ? parsed
 4190                : ThemeType.System;
 191        }
 2192        catch
 193        {
 194            // On any JS failure, treat as "no stored value".
 2195            return ThemeType.System;
 196        }
 7197    }
 198
 199    /// <summary>Determines whether cancellation has been requested for this component's operations.</summary>
 200    /// <returns>
 201    /// <c>true</c> if there is no active <see cref="CancellationTokenSource" /> or the current token has been cancelled
 202    /// otherwise, <c>false</c>.
 203    /// </returns>
 204    private bool IsCancellationRequested()
 205    {
 27206        var cts = _cts;
 207
 27208        return cts is null || cts.IsCancellationRequested;
 209    }
 210
 211    /// <summary>
 212    /// Performs post-render initialization, wiring up JS interop, synchronizing stored theme, and setting document dire
 213    /// </summary>
 214    /// <param name="firstRender">
 215    /// <c>true</c> when this is the first time the component has been rendered; otherwise,
 216    /// <c>false</c>.
 217    /// </param>
 218    /// <returns>A task that completes when initialization steps have finished.</returns>
 219    protected override async Task OnAfterRenderAsync(bool firstRender)
 220    {
 5221        if (!firstRender)
 222        {
 1223            return;
 224        }
 225
 4226        ThemingService.ThemeChanged += OnThemeChangedAsync;
 227
 4228        _cts = new CancellationTokenSource();
 229
 4230        _module ??= await JsRuntime
 4231            .InvokeAsync<IJSObjectReference>(
 4232                identifier: "import",
 4233                cancellationToken: _cts.Token,
 4234                "./AryThemeProvider.razor.js"
 4235            )
 4236            .ConfigureAwait(continueOnCapturedContext: false);
 237
 4238        var storedType = await GetStoredTypeAsync(cancellationToken: _cts.Token)
 4239            .ConfigureAwait(continueOnCapturedContext: false);
 240
 241        // Always adopt the stored theme when it differs from the current service value,
 242        // including ThemeType.System, so that ThemingService.StoredType is driven by browser storage.
 4243        if (storedType != ThemingService.StoredType)
 244        {
 1245            ThemingService.SetStoredType(themeType: storedType);
 246        }
 247
 4248        if (ThemingService.StoredType == ThemeType.System)
 249        {
 3250            await StartDetectAsync(cancellationToken: _cts.Token)
 3251                .ConfigureAwait(continueOnCapturedContext: false);
 252        }
 253
 4254        var detected = ThemingService.StoredType == ThemeType.System
 4255            ? await DetectThemeAsync(cancellationToken: _cts.Token)
 4256                .ConfigureAwait(continueOnCapturedContext: false)
 4257            : null;
 258
 4259        var effectiveType = detected ?? (ThemingService.StoredType == ThemeType.System
 4260            ? ThemeType.Light
 4261            : ThemingService.StoredType);
 262
 4263        UpdateEffectiveType(effectiveType: effectiveType);
 264
 4265        await UpdateStoredTypeAsync(
 4266                storedType: ThemingService.StoredType,
 4267                cancellationToken: _cts.Token
 4268            )
 4269            .ConfigureAwait(continueOnCapturedContext: false);
 270
 4271        await SetDirectionAsync().ConfigureAwait(continueOnCapturedContext: false);
 5272    }
 273
 274    /// <summary>
 275    /// Invoked by the framework when parameter values or cascading values change. Detects whether the effective UI cult
 276    /// changed (either through <see cref="Culture" /> or <see cref="CultureInfo.CurrentUICulture" />) and, when it has,
 277    /// updates document direction and language attributes by calling <see cref="SetDirectionAsync" />.
 278    /// </summary>
 279    /// <remarks>
 280    /// This method ensures RTL/LTR changes are applied reactively when the application culture updates via
 281    /// <c>CascadingLocalization</c>.
 282    /// </remarks>
 283    /// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
 284    protected override async Task OnParametersSetAsync()
 285    {
 6286        await base.OnParametersSetAsync();
 287
 6288        var currentCulture = Culture ?? CultureInfo.CurrentUICulture;
 289
 6290        if (_lastCulture?.Name != currentCulture.Name)
 291        {
 5292            _lastCulture = currentCulture;
 293
 5294            await SetDirectionAsync();
 295        }
 6296    }
 297
 298    /// <summary>
 299    /// Handles <see cref="IThemingService.ThemeChanged" /> events, synchronizing local state and requesting re-render.
 300    /// </summary>
 301    /// <param name="sender">The source of the event (typically the <see cref="IThemingService" /> instance).</param>
 302    /// <param name="e">Event data (unused).</param>
 303    /// <remarks>
 304    /// This method is intentionally <c>async void</c> to conform to the event handler pattern. It avoids throwing excep
 305    /// to the Blazor renderer, instead catching and swallowing failures from asynchronous work.
 306    /// </remarks>
 307    private async void OnThemeChangedAsync(object? sender, EventArgs e)
 308    {
 3309        var cts = _cts;
 310
 3311        if (cts is null || cts.IsCancellationRequested)
 312        {
 1313            return;
 314        }
 315
 316        try
 317        {
 2318            if (_storedType != ThemingService.StoredType)
 319            {
 1320                await UpdateStoredTypeAsync(
 1321                        storedType: ThemingService.StoredType,
 1322                        cancellationToken: cts.Token
 1323                    )
 1324                    .ConfigureAwait(continueOnCapturedContext: false);
 325            }
 326
 2327            var effectiveType = ThemingService.EffectiveType;
 328
 2329            if (_effectiveType == effectiveType)
 330            {
 1331                return;
 332            }
 333
 1334            _effectiveType = effectiveType;
 1335            await InvokeAsync(workItem: StateHasChanged);
 0336        }
 1337        catch
 338        {
 339            // Intentionally ignore errors from theme change propagation to avoid breaking the render loop.
 1340        }
 3341    }
 342
 343    /// <summary>Parses a raw theme type string into a <see cref="ThemeType" /> value, handling known aliases.</summary>
 344    /// <param name="value">The raw string value returned from JavaScript.</param>
 345    /// <returns>
 346    /// The corresponding <see cref="ThemeType" /> when recognized; otherwise <see cref="ThemeType.System" />.
 347    /// </returns>
 348    private static ThemeType ParseThemeType(string? value)
 16349        => value?.ToLowerInvariant() switch
 16350        {
 5351            "highcontrast" or "hc" or "forced" or "highcontrastlight" or "hcl" => ThemeType.HighContrastLight,
 2352            "highcontrastdark" or "hcd" => ThemeType.HighContrastDark,
 2353            "dark" => ThemeType.Dark,
 1354            "light" => ThemeType.Light,
 6355            _ => ThemeType.System
 16356        };
 357
 358    /// <summary>Sets the document direction and language attributes based on the current or cascaded culture.</summary>
 359    /// <returns>A task that completes when the JS interop calls have finished or failed.</returns>
 360    /// <remarks>
 361    /// This method does not currently support cancellation because it is short-lived and best-effort. It silently ignor
 362    /// failures (for example, during prerendering when JS is not available).
 363    /// </remarks>
 364    public async Task SetDirectionAsync()
 365    {
 11366        var culture = Culture ?? CultureInfo.CurrentUICulture;
 367
 11368        var dir = culture.TextInfo.IsRightToLeft
 11369            ? "rtl"
 11370            : "ltr";
 371
 11372        var lang = culture.IetfLanguageTag;
 373
 374        try
 375        {
 11376            await JsRuntime.InvokeVoidAsync(identifier: "document.documentElement.setAttribute", "dir", dir);
 11377            await JsRuntime.InvokeVoidAsync(identifier: "document.documentElement.setAttribute", "lang", lang);
 378
 11379            await JsRuntime.InvokeVoidAsync(
 11380                identifier: "(d,cls,add)=>add?d.body.classList.add(cls):d.body.classList.remove(cls)",
 11381                "document",
 11382                "rtl",
 11383                dir == "rtl"
 11384            );
 11385        }
 0386        catch
 387        {
 388            // Code Coverage: Unreachable code. Ignore if JS is not ready (e.g., during prerendering).
 0389        }
 11390    }
 391
 392    /// <summary>Receives theme updates from JavaScript when system theme detection fires.</summary>
 393    /// <param name="raw">The raw theme string supplied by the JS module.</param>
 394    [JSInvokable]
 395    public void SetFromJs(string raw)
 396    {
 11397        var effectiveType = ParseThemeType(value: raw);
 11398        UpdateEffectiveType(effectiveType: effectiveType);
 11399    }
 400
 401    /// <summary>Persists the current stored theme type to browser storage via the JS module.</summary>
 402    /// <param name="cancellationToken">A token used to cancel the storage operation.</param>
 403    /// <returns>A task that completes when the value has been sent to JS or an error has been ignored.</returns>
 404    private async Task SetStoredTypeAsync(CancellationToken cancellationToken = default)
 405    {
 6406        if (_module is null || IsCancellationRequested())
 407        {
 0408            return;
 409        }
 410
 411        try
 412        {
 6413            _ = await _module
 6414                .InvokeAsync<bool>(
 6415                    identifier: "setStoredTheme",
 6416                    cancellationToken: cancellationToken,
 6417                    StorageKey,
 6418                    _storedType.ToString()
 6419                )
 6420                .ConfigureAwait(continueOnCapturedContext: false);
 4421        }
 2422        catch
 423        {
 424            // Code Coverage: Unreachable code. Intentionally ignored: storage may be blocked or unavailable.
 2425        }
 6426    }
 427
 428    /// <summary>Starts system theme detection via the JS module if it is not already active.</summary>
 429    /// <param name="cancellationToken">A token used to cancel the initialization operation.</param>
 430    /// <returns>A task that completes when initialization has finished or failed.</returns>
 431    private async Task StartDetectAsync(CancellationToken cancellationToken = default)
 432    {
 5433        if (_isStarted || _module is null || IsCancellationRequested())
 434        {
 435            // Code coverage: Unreachable code.
 0436            return;
 437        }
 438
 439        try
 440        {
 5441            _dotNetRef ??= DotNetObjectReference.Create(value: this);
 442
 5443            await _module
 5444                .InvokeVoidAsync(
 5445                    identifier: "init",
 5446                    cancellationToken: cancellationToken,
 5447                    _host,
 5448                    _dotNetRef
 5449                )
 5450                .ConfigureAwait(continueOnCapturedContext: false);
 451
 4452            _isStarted = true;
 4453        }
 1454        catch
 455        {
 1456            _dotNetRef?.Dispose();
 1457            _dotNetRef = null;
 1458            _isStarted = false;
 1459        }
 5460    }
 461
 462    /// <summary>Stops system theme detection and detaches JS listeners.</summary>
 463    /// <returns>A task that completes when listeners have been detached or an error has been ignored.</returns>
 464    private async Task StopDetectAsync()
 465    {
 4466        if (!_isStarted || IsCancellationRequested())
 467        {
 3468            return;
 469        }
 470
 471        try
 472        {
 1473            if (_module is not null)
 474            {
 1475                await _module.InvokeVoidAsync(identifier: "dispose", _host)
 1476                    .ConfigureAwait(continueOnCapturedContext: false);
 477            }
 1478        }
 0479        catch
 480        {
 481            // Code coverage: Unreachable code. Ignore failures during teardown.
 0482        }
 483        finally
 484        {
 1485            _dotNetRef?.Dispose();
 1486            _dotNetRef = null;
 1487            _isStarted = false;
 488        }
 4489    }
 490
 491    /// <summary>Updates the effective theme type both locally and in the theming service when it has changed.</summary>
 492    /// <param name="effectiveType">The new effective <see cref="ThemeType" /> to apply.</param>
 493    private void UpdateEffectiveType(ThemeType effectiveType)
 494    {
 15495        if (_effectiveType == effectiveType)
 496        {
 2497            return;
 498        }
 499
 13500        _effectiveType = effectiveType;
 13501        ThemingService.SetEffectiveType(themeType: effectiveType);
 13502    }
 503
 504    /// <summary>Updates the stored theme type, managing detection lifecycle and persistence.</summary>
 505    /// <param name="storedType">The new stored <see cref="ThemeType" /> value.</param>
 506    /// <param name="cancellationToken">A token used to cancel JS-backed operations.</param>
 507    /// <returns>A task that completes when detection and storage updates have finished.</returns>
 508    private async Task UpdateStoredTypeAsync(ThemeType storedType, CancellationToken cancellationToken = default)
 509    {
 7510        if (_storedType == storedType)
 511        {
 3512            return;
 513        }
 514
 4515        _storedType = storedType;
 516
 4517        if (_storedType is ThemeType.System)
 518        {
 1519            await StartDetectAsync(cancellationToken: cancellationToken)
 1520                .ConfigureAwait(continueOnCapturedContext: false);
 521        }
 522        else
 523        {
 3524            await StopDetectAsync().ConfigureAwait(continueOnCapturedContext: false);
 525        }
 526
 4527        await SetStoredTypeAsync(cancellationToken: cancellationToken)
 4528            .ConfigureAwait(continueOnCapturedContext: false);
 7529    }
 530}