From df7b4151eb0dcc883f1c32af0042833b437b387d Mon Sep 17 00:00:00 2001 From: natsoni Date: Fri, 9 Jan 2026 12:11:12 +0100 Subject: [PATCH 1/3] improve loading state management in theme service --- .../block-overview-graph.component.ts | 15 +++-- .../components/fees-box/fees-box.component.ts | 10 ++-- .../mempool-blocks.component.ts | 8 +++ .../theme-selector.component.scss | 4 ++ .../theme-selector.component.ts | 16 +++--- frontend/src/app/services/theme.service.ts | 57 +++++++++++-------- 6 files changed, 70 insertions(+), 40 deletions(-) diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index f0a28f071..58f3c8096 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -64,7 +64,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @ViewChild('blockCanvas') canvas: ElementRef; - themeChangedSubscription: Subscription; + themeStateSubscription: Subscription; + loadedTheme = 'default'; gl: WebGLRenderingContext; animationFrameRequest: number; @@ -129,7 +130,11 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.gl) { this.initCanvas(); this.resizeCanvas(); - this.themeChangedSubscription = this.themeService.themeChanged$.subscribe(() => { + this.themeStateSubscription = this.themeService.themeState$.subscribe((state) => { + if (state.loading) { + return; + } + this.loadedTheme = state.theme; this.scene.setColorFunction(this.getColorFunction()); }); } @@ -184,7 +189,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } this.vertexArray.destroy(); this.vertexArray = null; - this.themeChangedSubscription?.unsubscribe(); + this.themeStateSubscription?.unsubscribe(); this.searchSubscription?.unsubscribe(); } @@ -670,13 +675,13 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On break; } if (matches) { - if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') { + if (this.loadedTheme !== 'contrast' && this.loadedTheme !== 'bukele') { return (gradient === 'age') ? ageColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)) : defaultColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)); } else { return (gradient === 'age') ? ageColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000)) : contrastColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000)); } } else { - if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') { + if (this.loadedTheme !== 'contrast' && this.loadedTheme !== 'bukele') { return (gradient === 'age') ? { r: 1, g: 1, b: 1, a: 0.05 } : defaultColorFunction( tx, defaultColors.unmatchedfee, diff --git a/frontend/src/app/components/fees-box/fees-box.component.ts b/frontend/src/app/components/fees-box/fees-box.component.ts index b0686ab2e..728b2be9e 100644 --- a/frontend/src/app/components/fees-box/fees-box.component.ts +++ b/frontend/src/app/components/fees-box/fees-box.component.ts @@ -16,7 +16,7 @@ import { ThemeService } from '@app/services/theme.service'; export class FeesBoxComponent implements OnInit, OnDestroy { isLoading$: Observable; recommendedFees$: Observable; - themeSubscription: Subscription; + themeStateSubscription: Subscription; gradient = 'linear-gradient(to right, var(--skeleton-bg), var(--skeleton-bg))'; noPriority = 'var(--skeleton-bg)'; fees: Recommendedfees; @@ -42,8 +42,10 @@ export class FeesBoxComponent implements OnInit, OnDestroy { } ) ); - this.themeSubscription = this.themeService.themeChanged$.subscribe(() => { - this.setFeeGradient(); + this.themeStateSubscription = this.themeService.themeState$.subscribe((state) => { + if (!state.loading) { + this.setFeeGradient(); + } }); } @@ -66,6 +68,6 @@ export class FeesBoxComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.themeSubscription.unsubscribe(); + this.themeStateSubscription.unsubscribe(); } } diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index e4eeaf8a2..6d8c180c6 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -50,6 +50,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { blockDisplayMode: 'size' | 'fees'; blockTransformation = {}; blocksSubscription: Subscription; + themeStateSubscription: Subscription; mempoolBlocksFull: MempoolBlock[] = []; mempoolBlockStyles = []; @@ -146,6 +147,12 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.reduceEmptyBlocksToFitScreen(this.mempoolEmptyBlocks); this.isTabHiddenSubscription = this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); + this.themeStateSubscription = this.themeService.themeState$.subscribe((state) => { + if (!state.loading) { + this.updateMempoolBlockStyles(); + this.cd.markForCheck(); + } + }); this.loadingBlocks$ = combineLatest([ this.stateService.isLoadingWebSocket$, this.stateService.isLoadingMempool$ @@ -300,6 +307,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.networkSubscription.unsubscribe(); this.blockDisplayModeSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe(); + this.themeStateSubscription.unsubscribe(); this.chainTipSubscription.unsubscribe(); this.keySubscription.unsubscribe(); this.isTabHiddenSubscription.unsubscribe(); diff --git a/frontend/src/app/components/theme-selector/theme-selector.component.scss b/frontend/src/app/components/theme-selector/theme-selector.component.scss index afdcf2980..df9585301 100644 --- a/frontend/src/app/components/theme-selector/theme-selector.component.scss +++ b/frontend/src/app/components/theme-selector/theme-selector.component.scss @@ -1,3 +1,7 @@ .custom-select { width: 100px; } + +.custom-select:disabled { + opacity: 0.6; +} diff --git a/frontend/src/app/components/theme-selector/theme-selector.component.ts b/frontend/src/app/components/theme-selector/theme-selector.component.ts index dfce46c2b..49141e0e9 100644 --- a/frontend/src/app/components/theme-selector/theme-selector.component.ts +++ b/frontend/src/app/components/theme-selector/theme-selector.component.ts @@ -13,7 +13,7 @@ import { Subscription } from 'rxjs'; export class ThemeSelectorComponent implements OnInit { themeForm: UntypedFormGroup; themes = ['default', 'contrast', 'softsimon', 'bukele']; - themeSubscription: Subscription; + themeStateSubscription: Subscription; constructor( private formBuilder: UntypedFormBuilder, @@ -24,11 +24,13 @@ export class ThemeSelectorComponent implements OnInit { this.themeForm = this.formBuilder.group({ theme: ['default'] }); - this.themeForm.get('theme')?.setValue(this.themeService.theme); - // Subscribe to theme changes because two instances of this component exist - this.themeSubscription = this.themeService.themeChanged$.subscribe(() => { - if (this.themeForm.get('theme')?.value !== this.themeService.theme){ - this.themeForm.get('theme')?.setValue(this.themeService.theme); + this.themeStateSubscription = this.themeService.themeState$.subscribe(({ theme, loading }) => { + console.log('Theme state changed:', theme, loading); + this.themeForm.get('theme')?.setValue(theme, { emitEvent: false }); + if (loading) { + this.themeForm.get('theme')?.disable({ emitEvent: false }); + } else { + this.themeForm.get('theme')?.enable({ emitEvent: false }); } }); } @@ -39,6 +41,6 @@ export class ThemeSelectorComponent implements OnInit { } ngOnDestroy() { - this.themeSubscription.unsubscribe(); + this.themeStateSubscription.unsubscribe(); } } diff --git a/frontend/src/app/services/theme.service.ts b/frontend/src/app/services/theme.service.ts index 2453acf05..0c524510e 100644 --- a/frontend/src/app/services/theme.service.ts +++ b/frontend/src/app/services/theme.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Subject } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { defaultMempoolFeeColors, contrastMempoolFeeColors } from '@app/app.constants'; import { StorageService } from '@app/services/storage.service'; import { StateService } from '@app/services/state.service'; @@ -10,7 +10,7 @@ import { StateService } from '@app/services/state.service'; export class ThemeService { style: HTMLLinkElement | null = null; theme: string = 'default'; - themeChanged$: Subject = new Subject(); + themeState$: BehaviorSubject<{ theme: string; loading: boolean; }>; mempoolFeeColors: string[] = defaultMempoolFeeColors; constructor( @@ -23,6 +23,7 @@ export class ThemeService { theme = 'default'; this.storageService.setValue('theme-preference', 'default'); } + this.themeState$ = new BehaviorSubject({ theme, loading: false }); this.apply(theme); } @@ -32,33 +33,41 @@ export class ThemeService { } this.theme = theme; - if (theme !== 'default') { - this.mempoolFeeColors = (theme === 'contrast' || theme === 'bukele') ? contrastMempoolFeeColors : defaultMempoolFeeColors; - try { - if (!this.style) { - this.style = document.createElement('link'); - this.style.rel = 'stylesheet'; - this.style.href = `${theme}.css`; - this.style.onerror = (): void => { // something went wrong (eg the css resource does not exist, revert to default) - this.apply('default'); - }; - document.head.appendChild(this.style); // load the css now - } else { - this.style.href = `${theme}.css`; - } - } catch (err) { - console.log('failed to apply theme stylesheet: ', err); - } - } else { - this.mempoolFeeColors = defaultMempoolFeeColors; + if (theme === 'default') { if (this.style) { this.style.remove(); this.style = null; } + if (!this.stateService.env.customize?.theme) { + this.storageService.setValue('theme-preference', theme); + } + this.mempoolFeeColors = defaultMempoolFeeColors; + this.themeState$.next({ theme, loading: false }); + return; } - if (!this.stateService.env.customize?.theme) { - this.storageService.setValue('theme-preference', theme); + + // Load theme stylesheet + this.themeState$.next({ theme, loading: true }); + try { + if (!this.style) { + this.style = document.createElement('link'); + this.style.rel = 'stylesheet'; + document.head.appendChild(this.style); // load the css now + } + + this.style.onload = () => { + this.mempoolFeeColors = theme === 'contrast' || theme === 'bukele' ? contrastMempoolFeeColors : defaultMempoolFeeColors; + this.themeState$.next({ theme, loading: false }); + }; + this.style.onerror = () => this.apply('default'); + this.style.href = `${theme}.css`; + + if (!this.stateService.env.customize?.theme) { + this.storageService.setValue('theme-preference', theme); + } + } catch (err) { + console.log('failed to apply theme stylesheet: ', err); + this.apply('default'); } - this.themeChanged$.next(this.theme); } } From c0891ead529f232b8ede92b2f9b9195d92c3eb94 Mon Sep 17 00:00:00 2001 From: natsoni Date: Fri, 9 Jan 2026 16:39:04 +0100 Subject: [PATCH 2/3] Prevent CSS issues on initial load in Safari --- frontend/src/app/services/theme.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/app/services/theme.service.ts b/frontend/src/app/services/theme.service.ts index 0c524510e..4a6e2dfae 100644 --- a/frontend/src/app/services/theme.service.ts +++ b/frontend/src/app/services/theme.service.ts @@ -12,6 +12,7 @@ export class ThemeService { theme: string = 'default'; themeState$: BehaviorSubject<{ theme: string; loading: boolean; }>; mempoolFeeColors: string[] = defaultMempoolFeeColors; + initialLoad: boolean = true; constructor( private storageService: StorageService, @@ -52,10 +53,17 @@ export class ThemeService { if (!this.style) { this.style = document.createElement('link'); this.style.rel = 'stylesheet'; + if (this.initialLoad) { + this.style.media = 'print'; // Prevent white flash and other CSS issues when using custom theme on initial app load in Safari + } document.head.appendChild(this.style); // load the css now } this.style.onload = () => { + if (this.initialLoad) { + this.style.media = 'all'; + this.initialLoad = false; + } this.mempoolFeeColors = theme === 'contrast' || theme === 'bukele' ? contrastMempoolFeeColors : defaultMempoolFeeColors; this.themeState$.next({ theme, loading: false }); }; From ddd996a9a579211bbdef877a22bbebfd39a68847 Mon Sep 17 00:00:00 2001 From: natsoni Date: Sat, 10 Jan 2026 14:58:20 +0100 Subject: [PATCH 3/3] Add OnDestroy to theme selector component, remove console log --- .../components/theme-selector/theme-selector.component.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/theme-selector/theme-selector.component.ts b/frontend/src/app/components/theme-selector/theme-selector.component.ts index 49141e0e9..72846de6b 100644 --- a/frontend/src/app/components/theme-selector/theme-selector.component.ts +++ b/frontend/src/app/components/theme-selector/theme-selector.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { ThemeService } from '@app/services/theme.service'; import { Subscription } from 'rxjs'; @@ -10,7 +10,7 @@ import { Subscription } from 'rxjs'; standalone: false, changeDetection: ChangeDetectionStrategy.OnPush }) -export class ThemeSelectorComponent implements OnInit { +export class ThemeSelectorComponent implements OnInit, OnDestroy { themeForm: UntypedFormGroup; themes = ['default', 'contrast', 'softsimon', 'bukele']; themeStateSubscription: Subscription; @@ -25,7 +25,6 @@ export class ThemeSelectorComponent implements OnInit { theme: ['default'] }); this.themeStateSubscription = this.themeService.themeState$.subscribe(({ theme, loading }) => { - console.log('Theme state changed:', theme, loading); this.themeForm.get('theme')?.setValue(theme, { emitEvent: false }); if (loading) { this.themeForm.get('theme')?.disable({ emitEvent: false });