Browse Source

Make Avalonia.Browser work on .NET 8 (#13312)

* Update SkiaSharp/HarfBuzzSharp and use proper native bits, make it configurable

* Run UiThreadRender jobs on each animation request in WASM

* Update ControlCatalog to match our current templates

* Add TaskContinuationOptions.ExecuteSynchronously in compositing engine where it's missed

* Log invalid rendering configuration

* Use setInterval isntead of Timer on WASM

* Minor fixes

* Implement BrowserDispatcherImpl and avoid possible memory leak
Max Katz 2 years ago
parent
commit
020cf9ff21

+ 0 - 0
samples/ControlCatalog.Browser/Logo.svg → samples/ControlCatalog.Browser/AppBundle/Logo.svg


+ 74 - 0
samples/ControlCatalog.Browser/AppBundle/app.css

@@ -0,0 +1,74 @@
+:root {
+    --sat: env(safe-area-inset-top);
+    --sar: env(safe-area-inset-right);
+    --sab: env(safe-area-inset-bottom);
+    --sal: env(safe-area-inset-left);
+}
+
+/* HTML styles for the splash screen */
+
+.highlight {
+    color: white;
+    font-size: 2.5rem;
+    display: block;
+}
+
+.purple {
+    color: #8b44ac;
+}
+
+.icon {
+    opacity: 0.05;
+    height: 35%;
+    width: 35%;
+    position: absolute;
+    background-repeat: no-repeat;
+    right: 0px;
+    bottom: 0px;
+    margin-right: 3%;
+    margin-bottom: 5%;
+    z-index: 5000;
+    background-position: right bottom;
+    pointer-events: none;
+}
+
+#avalonia-splash a {
+    color: whitesmoke;
+    text-decoration: none;
+}
+
+.center {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 100vh;
+}
+
+#avalonia-splash {
+    position: relative;
+    height: 100%;
+    width: 100%;
+    color: whitesmoke;
+    background: #1b2a4e;
+    font-family: 'Nunito', sans-serif;
+    background-position: center;
+    background-size: cover;
+    background-repeat: no-repeat;
+    justify-content: center;
+    align-items: center;
+}
+
+.splash-close {
+    animation: fadeout 0.25s linear forwards;
+}
+
+@keyframes fadeout {
+    0% {
+        opacity: 100%;
+    }
+
+    100% {
+        opacity: 0;
+        visibility: collapse;
+    }
+}

+ 0 - 0
samples/ControlCatalog.Browser/embed.js → samples/ControlCatalog.Browser/AppBundle/embed.js


+ 0 - 0
samples/ControlCatalog.Browser/favicon.ico → samples/ControlCatalog.Browser/AppBundle/favicon.ico


+ 28 - 0
samples/ControlCatalog.Browser/AppBundle/index.html

@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<!--  Licensed to the .NET Foundation under one or more agreements. -->
+<!-- The .NET Foundation licenses this file to you under the MIT license. -->
+<html>
+
+<head>
+    <title>AvaloniaUI - ControlCatalog</title>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <link rel="stylesheet" href="./app.css" />
+</head>
+
+<body style="margin: 0px; overflow: hidden">
+<div id="out">
+    <div id="avalonia-splash">
+        <div class="center">
+            <h2 class="purple">
+                Powered by
+                <a class="highlight" href="https://www.avaloniaui.net/" target="_blank">Avalonia UI</a>
+            </h2>
+        </div>
+        <img class="icon" src="Logo.svg" alt="Avalonia Logo" />
+    </div>
+</div>
+<script type='module' src="./main.js"></script>
+</body>
+
+</html>

+ 3 - 2
samples/ControlCatalog.Browser/main.js → samples/ControlCatalog.Browser/AppBundle/main.js

@@ -1,7 +1,8 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-import { dotnet } from './dotnet.js'
+import { dotnet } from './dotnet.js' // NET 7
+//import { dotnet } from './_framework/dotnet.js' // NET 8+
 
 const is_browser = typeof window != "undefined";
 if (!is_browser) throw new Error(`Expected to be running in a browser`);
@@ -13,4 +14,4 @@ const dotnetRuntime = await dotnet
 
 const config = dotnetRuntime.getConfig();
 
-await dotnetRuntime.runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]);
+await dotnetRuntime.runMainAndExit(config.mainAssemblyName, [globalThis.location.href]);

+ 4 - 24
samples/ControlCatalog.Browser/ControlCatalog.Browser.csproj

@@ -1,29 +1,15 @@
 <Project Sdk="Microsoft.NET.Sdk">
+  <Import Project="..\..\src\Browser\Avalonia.Browser\Avalonia.Browser.props" />
+
   <PropertyGroup>
     <TargetFramework>net7.0</TargetFramework>
     <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
-    <WasmMainJSPath>main.js</WasmMainJSPath>
+    <WasmMainJSPath>AppBundle\main.js</WasmMainJSPath>
     <OutputType>Exe</OutputType>
     <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
     <MSBuildEnableWorkloadResolver>true</MSBuildEnableWorkloadResolver>
-    <WasmBuildNative>true</WasmBuildNative>
-    <EmccFlags>-sVERBOSE -sERROR_ON_UNDEFINED_SYMBOLS=0</EmccFlags>
-  </PropertyGroup>
-
-  <PropertyGroup Condition="'$(Configuration)'=='Release'">
-    <RunAOTCompilation>true</RunAOTCompilation>
-    <PublishTrimmed>true</PublishTrimmed>
-    <TrimMode>full</TrimMode>
-    <WasmBuildNative>true</WasmBuildNative>
-    <InvariantGlobalization>true</InvariantGlobalization>
-    <EmccCompileOptimizationFlag>-O2</EmccCompileOptimizationFlag>
-    <EmccLinkOptimizationFlag>-O2</EmccLinkOptimizationFlag>
   </PropertyGroup>
 
-  <ItemGroup>
-    <TrimmerRootDescriptor Include="Roots.xml" />
-  </ItemGroup>
-
   <ItemGroup>
     <ProjectReference Include="..\..\src\Skia\Avalonia.Skia\Avalonia.Skia.csproj" />
     <ProjectReference Include="..\..\src\Browser\Avalonia.Browser\Avalonia.Browser.csproj" />
@@ -31,14 +17,8 @@
   </ItemGroup>
 
   <ItemGroup>
-    <WasmExtraFilesToDeploy Include="index.html" />
-    <WasmExtraFilesToDeploy Include="main.js" />
-    <WasmExtraFilesToDeploy Include="embed.js" />
-    <WasmExtraFilesToDeploy Include="favicon.ico" />
-    <WasmExtraFilesToDeploy Include="Logo.svg" />
-    <WasmExtraFilesToDeploy Include="app.css" />
+    <WasmExtraFilesToDeploy Include="AppBundle\**" />
   </ItemGroup>
 
-  <Import Project="..\..\src\Browser\Avalonia.Browser\Avalonia.Browser.props" />
   <Import Project="..\..\src\Browser\Avalonia.Browser\Avalonia.Browser.targets" />
 </Project>

+ 2 - 1
samples/ControlCatalog.Browser/EmbedSample.Browser.cs

@@ -4,6 +4,7 @@ using Avalonia.Platform;
 using Avalonia.Browser;
 
 using ControlCatalog.Pages;
+using System.Threading.Tasks;
 
 namespace ControlCatalog.Browser;
 
@@ -25,7 +26,7 @@ public class EmbedSampleWeb : INativeDemoControl
             _ = JSHost.ImportAsync("embed.js", "./embed.js").ContinueWith(_ =>
             {
                 EmbedInterop.AddAppButton(defaultHandle.Object);
-            });
+            }, TaskScheduler.FromCurrentSynchronizationContext());
 
             return defaultHandle;
         }

+ 5 - 1
samples/ControlCatalog.Browser/Program.cs

@@ -1,8 +1,9 @@
+using System.Diagnostics;
 using System.Runtime.Versioning;
 using System.Threading.Tasks;
 using Avalonia;
 using Avalonia.Browser;
-using Avalonia.Controls;
+using Avalonia.Logging;
 using ControlCatalog;
 using ControlCatalog.Browser;
 
@@ -12,7 +13,10 @@ internal partial class Program
 {
     public static async Task Main(string[] args)
     {
+        Trace.Listeners.Add(new ConsoleTraceListener());
+
         await BuildAvaloniaApp()
+            .LogToTrace(LogEventLevel.Warning)
             .AfterSetup(_ =>
             {
                 ControlCatalog.Pages.EmbedSample.Implementation = new EmbedSampleWeb();

+ 0 - 7
samples/ControlCatalog.Browser/Roots.xml

@@ -1,7 +0,0 @@
-<linker>
-  <assembly fullname="ControlCatalog" preserve="All" />
-  <assembly fullname="ControlCatalog.Web" preserve="All" />
-  <assembly fullname="Avalonia.Themes.Fluent" preserve="All" />
-  <assembly fullname="Avalonia.Themes.Simple" preserve="All" />
-  <assembly fullname="Avalonia.Controls.ColorPicker" preserve="All" />
-</linker>

+ 0 - 56
samples/ControlCatalog.Browser/app.css

@@ -1,56 +0,0 @@
-:root {
-    --sat: env(safe-area-inset-top);
-    --sar: env(safe-area-inset-right);
-    --sab: env(safe-area-inset-bottom);
-    --sal: env(safe-area-inset-left);
-}
-
-#out {
-    height: 100vh;
-    width: 100vw
-}
-
-#avalonia-splash {
-    position: relative;
-    height: 100%;
-    width: 100%;
-    color: whitesmoke;
-    background: #171C2C;
-    font-family: 'Nunito', sans-serif;
-    background-position: center;
-    background-size: cover;
-    background-repeat: no-repeat;
-}
-
-#avalonia-splash a{
-    color: whitesmoke;
-    text-decoration: none;
-}
-
-.center {
-    display: flex;
-    justify-content: center;
-    height: 250px;
-}
-
-.splash-close {
-    animation: slide 0.5s linear 1s forwards;
-}
-
-@keyframes slide {
-    0% {
-        top: 0%;
-    }
-
-    50% {
-        opacity: 80%;
-    }
-
-    100% {
-        top: 100%;
-        overflow: hidden;
-        opacity: 0;
-        display: none;
-        visibility: collapse;
-    }
-}

+ 0 - 31
samples/ControlCatalog.Browser/index.html

@@ -1,31 +0,0 @@
-<!DOCTYPE html>
-<!--  Licensed to the .NET Foundation under one or more agreements. -->
-<!-- The .NET Foundation licenses this file to you under the MIT license. -->
-<html>
-
-<head>
-    <title>AvaloniaUI - ControlCatalog</title>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <link rel="modulepreload" href="./main.js" />
-    <link rel="modulepreload" href="./dotnet.js" />
-    <link rel="modulepreload" href="./avalonia.js" />
-    <link rel="stylesheet" href="./app.css" />
-</head>
-
-<body style="margin: 0px">
-    <div id="out">
-        <div id="avalonia-splash">
-            <div class="center">
-                <h2>Powered by</h2>
-                <a class="navbar-brand" href="https://www.avaloniaui.net/" target="_blank">
-                    <img src="Logo.svg" alt="Avalonia Logo" width="30" height="24" />
-                    Avalonia
-                </a>
-            </div>
-        </div>
-    </div>
-    <script type='module' src="./main.js"></script>
-</body>
-
-</html>

+ 5 - 0
src/Avalonia.Base/Logging/LogArea.cs

@@ -74,5 +74,10 @@ namespace Avalonia.Logging
         /// The log event comes from macOS Platform
         /// </summary>
         public const string macOSPlatform = nameof(macOSPlatform);
+
+        /// <summary>
+        /// The log event comes from Browser Platform
+        /// </summary>
+        public static string BrowserPlatform => nameof(BrowserPlatform);
     }
 }

+ 5 - 2
src/Avalonia.Base/Media/MediaContext.Compositor.cs

@@ -1,4 +1,6 @@
 using System.Linq;
+using System.Threading.Tasks;
+
 using Avalonia.Platform;
 using Avalonia.Rendering.Composition;
 using Avalonia.Rendering.Composition.Transport;
@@ -20,7 +22,8 @@ partial class MediaContext
         _requestedCommits.Remove(compositor);
         _pendingCompositionBatches[compositor] = commit;
         commit.Processed.ContinueWith(_ =>
-            _dispatcher.Post(() => CompositionBatchFinished(compositor, commit), DispatcherPriority.Send));
+            _dispatcher.Post(() => CompositionBatchFinished(compositor, commit), DispatcherPriority.Send),
+            TaskContinuationOptions.ExecuteSynchronously);
         return commit;
     }
     
@@ -93,7 +96,7 @@ partial class MediaContext
         // Unit tests are assuming that they can call any API without setting up platforms
         if (AvaloniaLocator.Current.GetService<IPlatformRenderInterface>() == null)
             return;
-        
+
         if (compositor is
             {
                 UseUiThreadForSynchronousCommits: false,

+ 1 - 1
src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs

@@ -183,7 +183,7 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
             {
                 _queuedSceneInvalidation = false;
                 SceneInvalidated?.Invoke(this, new SceneInvalidatedEventArgs(_root, new Rect(_root.ClientSize)));
-            }, DispatcherPriority.Input));
+            }, DispatcherPriority.Input), TaskContinuationOptions.ExecuteSynchronously);
         }
     }
 

+ 2 - 1
src/Avalonia.Base/Rendering/Composition/Compositor.cs

@@ -108,7 +108,8 @@ namespace Avalonia.Rendering.Composition
                 var pending = _pendingBatch;
                 if (pending != null)
                     pending.Processed.ContinueWith(
-                        _ => Dispatcher.Post(_triggerCommitRequested, DispatcherPriority.Send));
+                        _ => Dispatcher.Post(_triggerCommitRequested, DispatcherPriority.Send),
+                        TaskContinuationOptions.ExecuteSynchronously);
                 else
                     _triggerCommitRequested();
             }

+ 3 - 1
src/Browser/Avalonia.Browser/Avalonia.Browser.props

@@ -1,5 +1,7 @@
 <Project>
   <PropertyGroup>
-    <EmccInitialHeapSize>16384000</EmccInitialHeapSize> <!-- must be a multiple of 64KiB, 1024000 * num MB, number grows -->
+    <ShouldIncludeAvaloniaJavaScript Condition=" '$(ShouldIncludeAvaloniaJavaScript)' == '' ">True</ShouldIncludeAvaloniaJavaScript>
+    <ShouldIncludeNativeSkiaSharp Condition=" '$(ShouldIncludeNativeSkiaSharp)' == '' ">True</ShouldIncludeNativeSkiaSharp>
+    <ShouldIncludeNativeHarfBuzzSharp Condition=" '$(ShouldIncludeNativeHarfBuzzSharp)' == '' ">True</ShouldIncludeNativeHarfBuzzSharp>
   </PropertyGroup>
 </Project>

+ 25 - 29
src/Browser/Avalonia.Browser/Avalonia.Browser.targets

@@ -1,37 +1,33 @@
 <Project>
-  <ItemGroup>
-    <WasmExtraFilesToDeploy Include="$(MSBuildThisFileDirectory)/wwwroot/**/*.*" />
-    <NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\3.1.7\libHarfBuzzSharp.a" />
-    <NativeFileReference Include="$(SkiaSharpStaticLibraryPath)\3.1.7\libSkiaSharp.a" />
-  </ItemGroup>
-
   <PropertyGroup>
-    <UseAvaloniaWasmDefaultOptimizations Condition="'$(UseAvaloniaWasmDefaultOptimizations)'==''">True</UseAvaloniaWasmDefaultOptimizations>
     <EmccExtraLDFlags>$(EmccExtraLDFlags) --js-library="$(MSBuildThisFileDirectory)\interop.js"</EmccExtraLDFlags>
     <EmccFlags>$(EmccExtraLDFlags) -sERROR_ON_UNDEFINED_SYMBOLS=0</EmccFlags>
-    <WasmBuildNative>true</WasmBuildNative>
   </PropertyGroup>
 
-  <PropertyGroup Condition="'$(UseAvaloniaWasmDefaultOptimizations)'=='True' And '$(Configuration)' == 'Release'">
-    <PublishTrimmed>true</PublishTrimmed>
-    <TrimMode>full</TrimMode>
-    <InvariantGlobalization>true</InvariantGlobalization>
-    <EmccCompileOptimizationFlag>-Oz</EmccCompileOptimizationFlag>
-    <EmccLinkOptimizationFlag>-Oz</EmccLinkOptimizationFlag>
-    <WasmEmitSymbolMap>false</WasmEmitSymbolMap>
-    <WasmNativeDebugSymbols>false</WasmNativeDebugSymbols>
-    <WasmDebugLevel>0</WasmDebugLevel>
-    <WasmEnableExceptionHandling>false</WasmEnableExceptionHandling>
-    <TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
-    <DebuggerSupport>false</DebuggerSupport>
-    <EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
-    <EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
-    <EventSourceSupport>false</EventSourceSupport>
-    <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
-    <MetadataUpdaterSupport>false</MetadataUpdaterSupport>
-    <UseNativeHttpHandler>true</UseNativeHttpHandler>
-    <UseSystemResourceKeys>true</UseSystemResourceKeys>
-    <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
-    <IncludeSatelliteDllsProjectOutputGroup>false</IncludeSatelliteDllsProjectOutputGroup>
+  <ItemGroup>
+    <WasmExtraFilesToDeploy Condition="'$(WasmRuntimeAssetsLocation)' == ''" Include="$(MSBuildThisFileDirectory)\wwwroot\**\*.*" />
+    <WasmExtraFilesToDeploy Condition="'$(WasmRuntimeAssetsLocation)' != ''" Include="$(MSBuildThisFileDirectory)\wwwroot\**\*.*" TargetPath="$(WasmRuntimeAssetsLocation)\%(FileName)%(Extension)" />
+  </ItemGroup>
+
+  <PropertyGroup Condition="'$(ShouldIncludeNativeSkiaSharp)' == 'True' or '$(ShouldIncludeNativeHarfBuzzSharp)' == 'True'">
+    <WasmBuildNative Condition="'$(WasmBuildNative)' == ''">true</WasmBuildNative>
   </PropertyGroup>
+
+  <ItemGroup Condition="'$(TargetFrameworkVersion)' != '' and '$(ShouldIncludeNativeSkiaSharp)' == 'True'">
+    <!-- net7.0 -->
+    <NativeFileReference Include="$(SkiaSharpStaticLibraryPath)\3.1.12\mt\*.a" Condition="!$([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '8.0')) and '$(WasmEnableThreads)' == 'True'" />
+    <NativeFileReference Include="$(SkiaSharpStaticLibraryPath)\3.1.12\st\*.a" Condition="!$([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '8.0')) and '$(WasmEnableThreads)' != 'True'" />
+    <!-- net8.0 -->
+    <NativeFileReference Include="$(SkiaSharpStaticLibraryPath)\3.1.34\mt\*.a" Condition="$([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '8.0')) and '$(WasmEnableThreads)' == 'True'" />
+    <NativeFileReference Include="$(SkiaSharpStaticLibraryPath)\3.1.34\st\*.a" Condition="$([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '8.0')) and '$(WasmEnableThreads)' != 'True'" />
+  </ItemGroup>
+
+  <ItemGroup Condition="'$(TargetFrameworkVersion)' != '' and '$(ShouldIncludeNativeHarfBuzzSharp)' == 'True'">
+    <!-- net7.0 -->
+    <NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\3.1.12\st\*.a" Condition="!$([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '8.0')) and '$(WasmEnableThreads)' != 'True'" />
+    <NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\3.1.12\mt\*.a" Condition="!$([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '8.0')) and '$(WasmEnableThreads)' == 'True'" />
+    <!-- net8.0 -->
+    <NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\3.1.34\st\*.a" Condition="$([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '8.0')) and '$(WasmEnableThreads)' != 'True'" />
+    <NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\3.1.34\mt\*.a" Condition="$([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '8.0')) and '$(WasmEnableThreads)' == 'True'" />
+  </ItemGroup>
 </Project>

+ 4 - 5
src/Browser/Avalonia.Browser/AvaloniaView.cs

@@ -12,6 +12,7 @@ using Avalonia.Controls.Platform;
 using Avalonia.Input;
 using Avalonia.Input.Raw;
 using Avalonia.Input.TextInput;
+using Avalonia.Logging;
 using Avalonia.Media;
 using Avalonia.Platform;
 using Avalonia.Rendering.Composition;
@@ -138,11 +139,8 @@ namespace Avalonia.Browser
             }
             else
             {
-                //var rasterInitialized = _interop.InitRaster();
-                //Console.WriteLine("raster initialized: {0}", rasterInitialized);
-
-                //_topLevelImpl.SetSurface(ColorType,
-                // new PixelSize((int)_canvasSize.Width, (int)_canvasSize.Height), _dpi, _interop.PutImageData);
+                Logger.TryGet(LogEventLevel.Error, LogArea.BrowserPlatform)?
+                    .Log(this, "[Avalonia]: Unable to initialize Canvas surface.");
             }
 
             CanvasHelper.SetCanvasSize(_canvas, (int)(_canvasSize.Width * _dpi), (int)(_canvasSize.Height * _dpi));
@@ -442,6 +440,7 @@ namespace Avalonia.Browser
                 return;
             }
 
+            Dispatcher.UIThread.RunJobs(DispatcherPriority.UiThreadRender);
             ManualTriggerRenderTimer.Instance.RaiseTick();
         }
 

+ 64 - 0
src/Browser/Avalonia.Browser/BrowserDispatcherImpl.cs

@@ -0,0 +1,64 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+
+using Avalonia.Browser.Interop;
+using Avalonia.Threading;
+
+namespace Avalonia.Browser;
+
+internal class BrowserDispatcherImpl : IDispatcherImpl
+{
+    private readonly Thread _thread;
+    private readonly Stopwatch _clock;
+    private bool _signaled;
+    private int? _timerId;
+
+    private readonly Action _timerCallback;
+    private readonly Action _signalCallback;
+
+    public BrowserDispatcherImpl()
+    {
+        _thread = Thread.CurrentThread;
+        _clock = Stopwatch.StartNew();
+
+        _timerCallback = () => Timer?.Invoke();
+        _signalCallback = () =>
+        {
+            _signaled = false;
+            Signaled?.Invoke();
+        };
+    }
+
+    public bool CurrentThreadIsLoopThread => Thread.CurrentThread == _thread;
+
+    public long Now => _clock.ElapsedMilliseconds;
+
+    public event Action? Signaled;
+    public event Action? Timer;
+
+    public void Signal()
+    {
+        if (_signaled)
+            return;
+
+        // NOTE: by HTML5 spec minimal timeout is 4ms, but Chrome seems to work well with 1ms as well.
+        var interval = 1;
+        CanvasHelper.SetTimeout(_signalCallback, interval);
+    }
+
+    public void UpdateTimer(long? dueTimeInMs)
+    {
+        if (_timerId is { } timerId)
+        {
+            _timerId = null;
+            CanvasHelper.ClearInterval(timerId);
+        }
+
+        if (dueTimeInMs.HasValue)
+        {
+            var interval = Math.Max(1, dueTimeInMs.Value - _clock.ElapsedMilliseconds);
+            _timerId = CanvasHelper.SetInterval(_timerCallback, (int)interval);
+        }
+    }
+}

+ 12 - 0
src/Browser/Avalonia.Browser/Interop/CanvasHelper.cs

@@ -39,4 +39,16 @@ internal static partial class CanvasHelper
         JSObject canvas,
         string canvasId,
         [JSMarshalAs<JSType.Function>] Action renderFrameCallback);
+
+    [JSImport("globalThis.setTimeout")]
+    public static partial int SetTimeout([JSMarshalAs<JSType.Function>] Action callback, int intervalMs);
+
+    [JSImport("globalThis.clearTimeout")]
+    public static partial int ClearTimeout(int id);
+
+    [JSImport("globalThis.setInterval")]
+    public static partial int SetInterval([JSMarshalAs<JSType.Function>] Action callback, int intervalMs);
+
+    [JSImport("globalThis.clearInterval")]
+    public static partial int ClearInterval(int id);
 }

+ 36 - 76
src/Browser/Avalonia.Browser/WindowingPlatform.cs

@@ -1,5 +1,4 @@
 using System;
-using System.Threading;
 using Avalonia.Browser.Interop;
 using Avalonia.Browser.Skia;
 using Avalonia.Input;
@@ -8,88 +7,49 @@ using Avalonia.Platform;
 using Avalonia.Rendering;
 using Avalonia.Threading;
 
-namespace Avalonia.Browser
-{
-    internal class BrowserWindowingPlatform : IWindowingPlatform, IPlatformThreadingInterface
-    {
-        private bool _signaled;
-        private static KeyboardDevice? s_keyboard;
-
-        public IWindowImpl CreateWindow() => throw new NotSupportedException("Browser doesn't support windowing platform. In order to display a single-view content, set ISingleViewApplicationLifetime.MainView.");
-
-        IWindowImpl IWindowingPlatform.CreateEmbeddableWindow()
-        {
-            throw new NotImplementedException("Browser doesn't support embeddable windowing platform.");
-        }
-
-        public ITrayIconImpl? CreateTrayIcon()
-        {
-            return null;
-        }
-
-        public static KeyboardDevice Keyboard => s_keyboard ??
-            throw new InvalidOperationException("BrowserWindowingPlatform not registered.");
-
-        public static void Register()
-        {
-            var instance = new BrowserWindowingPlatform();
-
-            s_keyboard = new KeyboardDevice();
-            AvaloniaLocator.CurrentMutable
-                .Bind<IRuntimePlatform>().ToSingleton<BrowserRuntimePlatform>()
-                .Bind<ICursorFactory>().ToSingleton<CssCursorFactory>()
-                .Bind<IKeyboardDevice>().ToConstant(s_keyboard)
-                .Bind<IPlatformSettings>().ToSingleton<BrowserPlatformSettings>()
-                .Bind<IPlatformThreadingInterface>().ToConstant(instance)
-                .Bind<IRenderTimer>().ToConstant(ManualTriggerRenderTimer.Instance)
-                .Bind<IWindowingPlatform>().ToConstant(instance)
-                .Bind<IPlatformGraphics>().ToConstant(new BrowserSkiaGraphics())
-                .Bind<IPlatformIconLoader>().ToSingleton<IconLoaderStub>()
-                .Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>();
+namespace Avalonia.Browser;
 
-            if (AvaloniaLocator.Current.GetService<BrowserPlatformOptions>() is { } options
-                && options.RegisterAvaloniaServiceWorker)
-            {
-                var swPath = AvaloniaModule.ResolveServiceWorkerPath();
-                AvaloniaModule.RegisterServiceWorker(swPath, options.AvaloniaServiceWorkerScope);
-            }
-        }
-
-        public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick)
-        {
-            return new Timer(_ =>
-            {
-                Dispatcher.UIThread.RunJobs(priority);
-                tick();
-            }, null, interval, interval);
-        }
+internal class BrowserWindowingPlatform : IWindowingPlatform
+{
+    private static KeyboardDevice? s_keyboard;
 
-        public void Signal(DispatcherPriority priority)
-        {
-            if (_signaled)
-                return;
+    public IWindowImpl CreateWindow() => throw new NotSupportedException("Browser doesn't support windowing platform. In order to display a single-view content, set ISingleViewApplicationLifetime.MainView.");
 
-            _signaled = true;
-            var interval = TimeSpan.FromMilliseconds(1);
+    IWindowImpl IWindowingPlatform.CreateEmbeddableWindow()
+    {
+        throw new NotImplementedException("Browser doesn't support embeddable windowing platform.");
+    }
 
-            IDisposable? disp = null;
-            disp = new Timer(_ =>
-            {
-                _signaled = false;
-                disp?.Dispose();
+    public ITrayIconImpl? CreateTrayIcon()
+    {
+        return null;
+    }
 
-                Signaled?.Invoke(null);
-            }, null, interval, interval);
-        }
+    public static KeyboardDevice Keyboard => s_keyboard ??
+        throw new InvalidOperationException("BrowserWindowingPlatform not registered.");
 
-        public bool CurrentThreadIsLoopThread
+    public static void Register()
+    {
+        var instance = new BrowserWindowingPlatform();
+
+        s_keyboard = new KeyboardDevice();
+        AvaloniaLocator.CurrentMutable
+            .Bind<IRuntimePlatform>().ToSingleton<BrowserRuntimePlatform>()
+            .Bind<ICursorFactory>().ToSingleton<CssCursorFactory>()
+            .Bind<IKeyboardDevice>().ToConstant(s_keyboard)
+            .Bind<IPlatformSettings>().ToSingleton<BrowserPlatformSettings>()
+            .Bind<IDispatcherImpl>().ToSingleton<BrowserDispatcherImpl>()
+            .Bind<IRenderTimer>().ToConstant(ManualTriggerRenderTimer.Instance)
+            .Bind<IWindowingPlatform>().ToConstant(instance)
+            .Bind<IPlatformGraphics>().ToConstant(new BrowserSkiaGraphics())
+            .Bind<IPlatformIconLoader>().ToSingleton<IconLoaderStub>()
+            .Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>();
+
+        if (AvaloniaLocator.Current.GetService<BrowserPlatformOptions>() is { } options
+            && options.RegisterAvaloniaServiceWorker)
         {
-            get
-            {
-                return true; // Browser is single threaded.
-            }
+            var swPath = AvaloniaModule.ResolveServiceWorkerPath();
+            AvaloniaModule.RegisterServiceWorker(swPath, options.AvaloniaServiceWorkerScope);
         }
-
-        public event Action<DispatcherPriority?>? Signaled;
     }
 }