Преглед на файлове

Headless Blazor client (#11112)

* Add Ignitor
* Finish headless Blazor client.
* Added support for click events.
* Move Ignitor into testassets folder.
- Also added Ignitor to the no deps solution.
* Add Ignitor tests to validate RenderBatchReader stays consistent.
N. Taylor Mullen преди 6 години
родител
ревизия
874050f1dd
променени са 22 файла, в които са добавени 1845 реда и са изтрити 3 реда
  1. 75 0
      src/Components/Components.sln
  2. 2 0
      src/Components/Components/src/Properties/AssemblyInfo.cs
  3. 3 1
      src/Components/ComponentsNoDeps.slnf
  4. 2 0
      src/Components/Server/src/Properties/AssemblyInfo.cs
  5. 11 0
      src/Components/test/Ignitor.Test/Ignitor.Test.csproj
  6. 347 0
      src/Components/test/Ignitor.Test/RenderBatchReaderTest.cs
  7. 1 1
      src/Components/test/testassets/ComponentsApp.App/Pages/Counter.razor
  8. 1 1
      src/Components/test/testassets/ComponentsApp.Server/ComponentsApp.Server.csproj
  9. 9 0
      src/Components/test/testassets/Ignitor/CommentNode.cs
  10. 15 0
      src/Components/test/testassets/Ignitor/ComponentNode.cs
  11. 84 0
      src/Components/test/testassets/Ignitor/ContainerNode.cs
  12. 473 0
      src/Components/test/testassets/Ignitor/ElementHive.cs
  13. 106 0
      src/Components/test/testassets/Ignitor/ElementNode.cs
  14. 16 0
      src/Components/test/testassets/Ignitor/Ignitor.csproj
  15. 12 0
      src/Components/test/testassets/Ignitor/IgnitorMessagePackHubProtocol.cs
  16. 15 0
      src/Components/test/testassets/Ignitor/MarkupNode.cs
  17. 10 0
      src/Components/test/testassets/Ignitor/Node.cs
  18. 196 0
      src/Components/test/testassets/Ignitor/NodeSerializer.cs
  19. 142 0
      src/Components/test/testassets/Ignitor/Program.cs
  20. 3 0
      src/Components/test/testassets/Ignitor/Properties/AssemblyInfo.cs
  21. 307 0
      src/Components/test/testassets/Ignitor/RenderBatchReader.cs
  22. 15 0
      src/Components/test/testassets/Ignitor/TextNode.cs

+ 75 - 0
src/Components/Components.sln

@@ -212,6 +212,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Co
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.SignalR.Protocols.Json", "..\SignalR\common\Protocols.Json\src\Microsoft.AspNetCore.SignalR.Protocols.Json.csproj", "{ED210157-461B-45BB-9D86-B81A62792C30}"
 EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.SignalR.Client", "..\SignalR\clients\csharp\Client\src\Microsoft.AspNetCore.SignalR.Client.csproj", "{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.SignalR.Client.Core", "..\SignalR\clients\csharp\Client.Core\src\Microsoft.AspNetCore.SignalR.Client.Core.csproj", "{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Http.Connections.Client", "..\SignalR\clients\csharp\Http.Connections.Client\src\Microsoft.AspNetCore.Http.Connections.Client.csproj", "{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ignitor", "test\testassets\Ignitor\Ignitor.csproj", "{A78CE874-76B7-46FE-8009-1ED5258BA0AA}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ignitor.Test", "test\Ignitor.Test\Ignitor.Test.csproj", "{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -1338,6 +1348,66 @@ Global
 		{ED210157-461B-45BB-9D86-B81A62792C30}.Release|x64.Build.0 = Release|Any CPU
 		{ED210157-461B-45BB-9D86-B81A62792C30}.Release|x86.ActiveCfg = Release|Any CPU
 		{ED210157-461B-45BB-9D86-B81A62792C30}.Release|x86.Build.0 = Release|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Debug|x64.Build.0 = Debug|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Debug|x86.Build.0 = Debug|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Release|Any CPU.Build.0 = Release|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Release|x64.ActiveCfg = Release|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Release|x64.Build.0 = Release|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Release|x86.ActiveCfg = Release|Any CPU
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0}.Release|x86.Build.0 = Release|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Debug|x64.Build.0 = Debug|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Debug|x86.Build.0 = Debug|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Release|Any CPU.Build.0 = Release|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Release|x64.ActiveCfg = Release|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Release|x64.Build.0 = Release|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Release|x86.ActiveCfg = Release|Any CPU
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB}.Release|x86.Build.0 = Release|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Debug|x64.Build.0 = Debug|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Debug|x86.Build.0 = Debug|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Release|Any CPU.Build.0 = Release|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Release|x64.ActiveCfg = Release|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Release|x64.Build.0 = Release|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Release|x86.ActiveCfg = Release|Any CPU
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0}.Release|x86.Build.0 = Release|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Debug|x64.Build.0 = Debug|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Debug|x86.Build.0 = Debug|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Release|Any CPU.Build.0 = Release|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Release|x64.ActiveCfg = Release|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Release|x64.Build.0 = Release|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Release|x86.ActiveCfg = Release|Any CPU
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA}.Release|x86.Build.0 = Release|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Debug|x64.Build.0 = Debug|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Debug|x86.Build.0 = Debug|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Release|Any CPU.Build.0 = Release|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Release|x64.ActiveCfg = Release|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Release|x64.Build.0 = Release|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Release|x86.ActiveCfg = Release|Any CPU
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -1438,6 +1508,11 @@ Global
 		{9088E4E4-B855-457F-AE9E-D86709A5E1F4} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
 		{3A4132B6-60DA-43A0-8E7B-4BF346F3247C} = {2FC10057-7A0A-4E34-8302-879925BC0102}
 		{ED210157-461B-45BB-9D86-B81A62792C30} = {2FC10057-7A0A-4E34-8302-879925BC0102}
+		{DA137BD4-F7F1-4D53-855F-5EC40CEA36B0} = {2FC10057-7A0A-4E34-8302-879925BC0102}
+		{0CDAB70B-71DC-43BE-ACB7-AD2EE3541FFB} = {2FC10057-7A0A-4E34-8302-879925BC0102}
+		{F88118E1-6F4A-4F89-B047-5FFD2889B9F0} = {2FC10057-7A0A-4E34-8302-879925BC0102}
+		{A78CE874-76B7-46FE-8009-1ED5258BA0AA} = {44E0D4F3-4430-4175-B482-0D1AEE4BB699}
+		{FC2A1EB0-A116-4689-92B7-239B1DCCF4CA} = {E9E9CF3C-CE9B-4282-B2BB-97EFC3872798}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {CC3C47E1-AD1A-4619-9CD3-E08A0148E5CE}

+ 2 - 0
src/Components/Components/src/Properties/AssemblyInfo.cs

@@ -5,3 +5,5 @@ using System.Runtime.CompilerServices;
 [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Browser.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
 [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Performance, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
 [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Server.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
+[assembly: InternalsVisibleTo("Ignitor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
+[assembly: InternalsVisibleTo("Ignitor.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

+ 3 - 1
src/Components/ComponentsNoDeps.slnf

@@ -29,7 +29,9 @@
       "test\\testassets\\ComponentsApp.App\\ComponentsApp.App.csproj",
       "test\\testassets\\ComponentsApp.Server\\ComponentsApp.Server.csproj",
       "test\\testassets\\TestContentPackage\\TestContentPackage.csproj",
-      "test\\testassets\\TestServer\\Components.TestServer.csproj"
+      "test\\testassets\\TestServer\\Components.TestServer.csproj",
+      "test\\testassets\\Ignitor\\Ignitor.csproj",
+      "test\\Ignitor.Test\\Ignitor.Test.csproj"
     ]
   }
 }

+ 2 - 0
src/Components/Server/src/Properties/AssemblyInfo.cs

@@ -2,5 +2,7 @@ using System.Runtime.CompilerServices;
 
 [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Blazor.DevServer, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
 [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Server.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
+[assembly: InternalsVisibleTo("Ignitor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
+[assembly: InternalsVisibleTo("Ignitor.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
 
 [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]

+ 11 - 0
src/Components/test/Ignitor.Test/Ignitor.Test.csproj

@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netcoreapp3.0</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\testassets\Ignitor\Ignitor.csproj" />
+  </ItemGroup>
+
+</Project>

+ 347 - 0
src/Components/test/Ignitor.Test/RenderBatchReaderTest.cs

@@ -0,0 +1,347 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Components.Server.Circuits;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Ignitor
+{
+    public class RenderBatchReaderTest
+    {
+        static object NullStringMarker = new object();
+
+        // All of these tests are copies from the RenderBatchWriterTest but converted to be round-trippable tests.
+
+        [Fact]
+        public void CanRoundTripEmptyRenderBatch()
+        {
+            // Arrange/Act
+            var bytes = RoundTripSerialize(new RenderBatch());
+
+            // Assert
+            AssertBinaryContents(bytes, /* startIndex */ 0,
+                0,  // Length of UpdatedComponents
+                0,  // Length of ReferenceFrames
+                0,  // Length of DisposedComponentIds
+                0,  // Length of DisposedEventHandlerIds
+
+                0,  // Index of UpdatedComponents
+                4,  // Index of ReferenceFrames
+                8,  // Index of DisposedComponentIds
+                12, // Index of DisposedEventHandlerIds
+                16  // Index of Strings
+            );
+            Assert.Equal(36, bytes.Length); // No other data
+        }
+
+        [Fact]
+        public void CanRoundTripUpdatedComponentsWithEmptyEdits()
+        {
+            // Arrange/Act
+            var bytes = RoundTripSerialize(new RenderBatch(
+                new ArrayRange<RenderTreeDiff>(new[]
+                {
+                    new RenderTreeDiff(123, default),
+                    new RenderTreeDiff(int.MaxValue, default),
+                }, 2),
+                default,
+                default,
+                default));
+
+            // Assert
+            AssertBinaryContents(bytes, /* startIndex */ 0,
+                // UpdatedComponents[0]
+                123, // ComponentId
+                0,   // Edits length
+
+                // UpdatedComponents[1]
+                int.MaxValue, // ComponentId
+                0,   // Edits length
+
+                2,   // Length of UpdatedComponents
+                0,   // Index of UpdatedComponents[0]
+                8,   // Index of UpdatedComponents[1]
+
+                0,   // Length of ReferenceFrames
+                0,   // Length of DisposedComponentIds
+                0,   // Length of DisposedEventHandlerIds
+
+                16,  // Index of UpdatedComponents
+                28,  // Index of ReferenceFrames
+                32,  // Index of DisposedComponentIds
+                36,  // Index of DisposedEventHandlerIds
+                40   // Index of strings
+            );
+            Assert.Equal(60, bytes.Length); // No other data
+        }
+
+        [Fact]
+        public void CanRoundTripEdits()
+        {
+            // Arrange/Act
+            var edits = new[]
+            {
+                default, // Skipped (because offset=1 below)
+                RenderTreeEdit.PrependFrame(456, 789),
+                RenderTreeEdit.RemoveFrame(101),
+                RenderTreeEdit.SetAttribute(102, 103),
+                RenderTreeEdit.RemoveAttribute(104, "Some removed attribute"),
+                RenderTreeEdit.UpdateText(105, 106),
+                RenderTreeEdit.StepIn(107),
+                RenderTreeEdit.StepOut(),
+                RenderTreeEdit.UpdateMarkup(108, 109),
+                RenderTreeEdit.RemoveAttribute(110, "Some removed attribute"), // To test deduplication
+            };
+            var bytes = RoundTripSerialize(new RenderBatch(
+                new ArrayRange<RenderTreeDiff>(new[]
+                {
+                    new RenderTreeDiff(123, new ArraySegment<RenderTreeEdit>(
+                        edits, 1, edits.Length - 1)) // Skip first to show offset is respected
+                }, 1),
+                default,
+                default,
+                default));
+
+            // Assert
+            var diffsStartIndex = ReadInt(bytes, bytes.Length - 20);
+            AssertBinaryContents(bytes, diffsStartIndex,
+                1,  // Number of diffs
+                0); // Index of diffs[0]
+
+            AssertBinaryContents(bytes, 0,
+                123, // Component ID for diff 0
+                9,  // diff[0].Edits.Count
+                RenderTreeEditType.PrependFrame, 456, 789, NullStringMarker,
+                RenderTreeEditType.RemoveFrame, 101, 0, NullStringMarker,
+                RenderTreeEditType.SetAttribute, 102, 103, NullStringMarker,
+                RenderTreeEditType.RemoveAttribute, 104, 0, "Some removed attribute",
+                RenderTreeEditType.UpdateText, 105, 106, NullStringMarker,
+                RenderTreeEditType.StepIn, 107, 0, NullStringMarker,
+                RenderTreeEditType.StepOut, 0, 0, NullStringMarker,
+                RenderTreeEditType.UpdateMarkup, 108, 109, NullStringMarker,
+                RenderTreeEditType.RemoveAttribute, 110, 0, "Some removed attribute"
+            );
+
+            // We can deduplicate attribute names
+            Assert.Equal(new[] { "Some removed attribute" }, ReadStringTable(bytes));
+        }
+
+        [Fact]
+        public void CanRoundTripReferenceFrames()
+        {
+            // Arrange/Act
+            var renderer = new FakeRenderer();
+            var bytes = RoundTripSerialize(new RenderBatch(
+                default,
+                new ArrayRange<RenderTreeFrame>(new[] {
+                    RenderTreeFrame.Attribute(123, "Attribute with string value", "String value"),
+                    RenderTreeFrame.Attribute(124, "Attribute with nonstring value", 1),
+                    RenderTreeFrame.Attribute(125, "Attribute with delegate value", new Action(() => { }))
+                        .WithAttributeEventHandlerId(789),
+                    RenderTreeFrame.ChildComponent(126, typeof(object))
+                        .WithComponentSubtreeLength(5678)
+                        .WithComponent(new ComponentState(renderer, 2000, new FakeComponent(), null)),
+                    RenderTreeFrame.ComponentReferenceCapture(127, value => { }, 1001),
+                    RenderTreeFrame.Element(128, "Some element")
+                        .WithElementSubtreeLength(1234),
+                    RenderTreeFrame.ElementReferenceCapture(129, value => { })
+                        .WithElementReferenceCaptureId("my unique ID"),
+                    RenderTreeFrame.Region(130)
+                        .WithRegionSubtreeLength(1234),
+                    RenderTreeFrame.Text(131, "Some text"),
+                    RenderTreeFrame.Markup(132, "Some markup"),
+                    RenderTreeFrame.Text(133, "\n\t  "),
+
+                    // Testing deduplication
+                    RenderTreeFrame.Attribute(134, "Attribute with string value", "String value"),
+                    RenderTreeFrame.Element(135, "Some element") // Will be deduplicated
+                        .WithElementSubtreeLength(999),
+                    RenderTreeFrame.Text(136, "Some text"), // Will not be deduplicated
+                    RenderTreeFrame.Markup(137, "Some markup"), // Will not be deduplicated
+                    RenderTreeFrame.Text(138, "\n\t  "), // Will be deduplicated
+                }, 16),
+                default,
+                default));
+
+            // Assert
+            var referenceFramesStartIndex = ReadInt(bytes, bytes.Length - 16);
+            AssertBinaryContents(bytes, referenceFramesStartIndex,
+                16, // Number of frames
+                RenderTreeFrameType.Attribute, "Attribute with string value", "String value", 0,
+                RenderTreeFrameType.Attribute, "Attribute with nonstring value", NullStringMarker, 0,
+                RenderTreeFrameType.Attribute, "Attribute with delegate value", NullStringMarker, 789,
+                RenderTreeFrameType.Component, 5678, 2000, 0,
+                RenderTreeFrameType.ComponentReferenceCapture, 0, 0, 0,
+                RenderTreeFrameType.Element, 1234, "Some element", 0,
+                RenderTreeFrameType.ElementReferenceCapture, "my unique ID", 0, 0,
+                RenderTreeFrameType.Region, 1234, 0, 0,
+                RenderTreeFrameType.Text, "Some text", 0, 0,
+                RenderTreeFrameType.Markup, "Some markup", 0, 0,
+                RenderTreeFrameType.Text, "\n\t  ", 0, 0,
+                RenderTreeFrameType.Attribute, "Attribute with string value", "String value", 0,
+                RenderTreeFrameType.Element, 999, "Some element", 0,
+                RenderTreeFrameType.Text, "Some text", 0, 0,
+                RenderTreeFrameType.Markup, "Some markup", 0, 0,
+                RenderTreeFrameType.Text, "\n\t  ", 0, 0
+            );
+
+            Assert.Equal(new[]
+            {
+                "Attribute with string value",
+                "String value",
+                "Attribute with nonstring value",
+                "Attribute with delegate value",
+                "Some element",
+                "my unique ID",
+                "Some text",
+                "Some markup",
+                "\n\t  ",
+                "String value",
+                "Some text",
+                "Some markup",
+            }, ReadStringTable(bytes));
+        }
+
+        private Span<byte> RoundTripSerialize(RenderBatch renderBatch)
+        {
+            var bytes = Serialize(renderBatch);
+            var roundTrippedRenderBatch = RenderBatchReader.Read(bytes);
+            var roundTrippedBytes = Serialize(roundTrippedRenderBatch);
+
+            return roundTrippedBytes;
+
+            Span<byte> Serialize(RenderBatch batch)
+            {
+                using (var ms = new MemoryStream())
+                using (var writer = new RenderBatchWriter(ms, leaveOpen: false))
+                {
+                    writer.Write(batch);
+                    return new Span<byte>(ms.ToArray(), 0, (int)ms.Length);
+                }
+            }
+        }
+
+        static string[] ReadStringTable(Span<byte> data)
+        {
+            var bytes = data.ToArray();
+
+            // The string table position is given by the final int, and continues
+            // until we get to the final set of top-level indices
+            var stringTableStartPosition = BitConverter.ToInt32(bytes, bytes.Length - 4);
+            var stringTableEndPositionExcl = bytes.Length - 20;
+
+            var result = new List<string>();
+            for (var entryPosition = stringTableStartPosition;
+                entryPosition < stringTableEndPositionExcl;
+                entryPosition += 4)
+            {
+                // The string table entries are all length-prefixed UTF8 blobs
+                var tableEntryPos = BitConverter.ToInt32(bytes, entryPosition);
+                var length = (int)ReadUnsignedLEB128(bytes, tableEntryPos, out var numLEB128Bytes);
+                var value = Encoding.UTF8.GetString(bytes, tableEntryPos + numLEB128Bytes, length);
+                result.Add(value);
+            }
+
+            return result.ToArray();
+        }
+
+        static void AssertBinaryContents(Span<byte> data, int startIndex, params object[] entries)
+        {
+            var bytes = data.ToArray();
+            var stringTableEntries = ReadStringTable(data);
+
+            using (var ms = new MemoryStream(bytes))
+            using (var reader = new BinaryReader(ms))
+            {
+                ms.Seek(startIndex, SeekOrigin.Begin);
+
+                foreach (var expectedEntryIterationVar in entries)
+                {
+                    // Assume enums are represented as ints
+                    var expectedEntry = expectedEntryIterationVar.GetType().IsEnum
+                        ? (int)expectedEntryIterationVar
+                        : expectedEntryIterationVar;
+
+                    if (expectedEntry is int expectedInt)
+                    {
+                        Assert.Equal(expectedInt, reader.ReadInt32());
+                    }
+                    else if (expectedEntry is string || expectedEntry == NullStringMarker)
+                    {
+                        // For strings, we have to look up the value in the table of strings
+                        // that appears at the end of the serialized data
+                        var indexIntoStringTable = reader.ReadInt32();
+                        var expectedString = expectedEntry as string;
+                        if (expectedString == null)
+                        {
+                            Assert.Equal(-1, indexIntoStringTable);
+                        }
+                        else
+                        {
+                            Assert.Equal(expectedString, stringTableEntries[indexIntoStringTable]);
+                        }
+                    }
+                    else
+                    {
+                        throw new InvalidOperationException($"Unsupported type: {expectedEntry.GetType().FullName}");
+                    }
+                }
+            }
+        }
+
+        static int ReadInt(Span<byte> bytes, int startOffset)
+            => BitConverter.ToInt32(bytes.Slice(startOffset, 4).ToArray(), 0);
+
+        public static uint ReadUnsignedLEB128(byte[] bytes, int startOffset, out int numBytesRead)
+        {
+            var result = (uint)0;
+            var shift = 0;
+            var currentByte = (byte)128;
+            numBytesRead = 0;
+
+            for (var count = 0; count < 4 && currentByte >= 128; count++)
+            {
+                currentByte = bytes[startOffset + count];
+                result += (uint)(currentByte & 0x7f) << shift;
+                shift += 7;
+                numBytesRead++;
+            }
+
+            return result;
+        }
+
+        class FakeComponent : IComponent
+        {
+            public void Configure(RenderHandle renderHandle)
+                => throw new NotImplementedException();
+
+            public Task SetParametersAsync(ParameterCollection parameters)
+                => throw new NotImplementedException();
+        }
+
+        class FakeRenderer : Renderer
+        {
+            public FakeRenderer()
+                : base(new ServiceCollection().BuildServiceProvider(), new RendererSynchronizationContext())
+            {
+            }
+
+            protected override void HandleException(Exception exception)
+            {
+                throw new NotImplementedException();
+            }
+
+            protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
+                => throw new NotImplementedException();
+        }
+    }
+}

+ 1 - 1
src/Components/test/testassets/ComponentsApp.App/Pages/Counter.razor

@@ -4,7 +4,7 @@
 
 <p>Current count: @currentCount</p>
 
-<button class="btn btn-primary" @onclick="@IncrementCount">Click me</button>
+<button class="btn btn-primary" @onclick="@IncrementCount" id="thecounter">Click me</button>
 
 @code {
     int currentCount = 0;

+ 1 - 1
src/Components/test/testassets/ComponentsApp.Server/ComponentsApp.Server.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
+<Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
     <TargetFramework>netcoreapp3.0</TargetFramework>

+ 9 - 0
src/Components/test/testassets/Ignitor/CommentNode.cs

@@ -0,0 +1,9 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Ignitor
+{
+    internal class LogicalContainerNode : ContainerNode
+    {
+    }
+}

+ 15 - 0
src/Components/test/testassets/Ignitor/ComponentNode.cs

@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Ignitor
+{
+    internal class ComponentNode : ContainerNode
+    {
+        private readonly int _componentId;
+
+        public ComponentNode(int componentId)
+        {
+            _componentId = componentId;
+        }
+    }
+}

+ 84 - 0
src/Components/test/testassets/Ignitor/ContainerNode.cs

@@ -0,0 +1,84 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+
+namespace Ignitor
+{
+    internal abstract class ContainerNode : Node
+    {
+        private readonly List<Node> _children;
+
+        protected ContainerNode()
+        {
+            _children = new List<Node>();
+        }
+
+        public IReadOnlyList<Node> Children => _children;
+
+        public void InsertLogicalChild(Node child, int childIndex)
+        {
+            if (child is LogicalContainerNode comment && comment.Children.Count > 0)
+            {
+                // There's nothing to stop us implementing support for this scenario, and it's not difficult
+                // (after inserting 'child' itself, also iterate through its logical children and physically
+                // put them as following-siblings in the DOM). However there's no scenario that requires it
+                // presently, so if we did implement it there'd be no good way to have tests for it.
+                throw new Exception("Not implemented: inserting non-empty logical container");
+            }
+
+            if (child.Parent != null)
+            {
+                // Likewise, we could easily support this scenario too (in this 'if' block, just splice
+                // out 'child' from the logical children array of its previous logical parent by using
+                // Array.prototype.indexOf to determine its previous sibling index).
+                // But again, since there's not currently any scenario that would use it, we would not
+                // have any test coverage for such an implementation.
+                throw new NotSupportedException("Not implemented: moving existing logical children");
+            }
+
+            if (childIndex < Children.Count)
+            {
+                // Insert
+                _children[childIndex] = child;
+            }
+            else
+            {
+                // Append
+                _children.Add(child);
+            }
+
+            child.Parent = this;
+        }
+
+        public ContainerNode CreateAndInsertContainer(int childIndex)
+        {
+            var containerElement = new LogicalContainerNode();
+            InsertLogicalChild(containerElement, childIndex);
+            return containerElement;
+        }
+
+        public ComponentNode CreateAndInsertComponent(int componentId, int childIndex)
+        {
+            var componentElement = new ComponentNode(componentId);
+            InsertLogicalChild(componentElement, childIndex);
+            return componentElement;
+        }
+
+        public void RemoveLogicalChild(int childIndex)
+        {
+            var childToRemove = Children[childIndex];
+            _children.RemoveAt(childIndex);
+
+            // If it's a logical container, also remove its descendants
+            if (childToRemove is LogicalContainerNode container)
+            {
+                while (container.Children.Count > 0)
+                {
+                    container.RemoveLogicalChild(0);
+                }
+            }
+        }
+    }
+}

+ 473 - 0
src/Components/test/testassets/Ignitor/ElementHive.cs

@@ -0,0 +1,473 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Ignitor
+{
+    internal class ElementHive
+    {
+        private const string SelectValuePropname = "_blazorSelectValue";
+
+        public Dictionary<int, ComponentNode> Components { get; } = new Dictionary<int, ComponentNode>();
+
+        public string SerializedValue => NodeSerializer.Serialize(this);
+
+        public void Update(RenderBatch batch)
+        {
+            for (var i = 0; i < batch.UpdatedComponents.Count; i++)
+            {
+                var diff = batch.UpdatedComponents.Array[i];
+                var componentId = diff.ComponentId;
+                var edits = diff.Edits;
+                UpdateComponent(batch, componentId, edits);
+            }
+
+            for (var i = 0; i < batch.DisposedComponentIDs.Count; i++)
+            {
+                DisposeComponent(batch.DisposedComponentIDs.Array[i]);
+            }
+
+            for (var i = 0; i < batch.DisposedEventHandlerIDs.Count; i++)
+            {
+                DisposeEventHandler(batch.DisposedEventHandlerIDs.Array[i]);
+            }
+        }
+
+        public bool TryFindElementById(string id, out ElementNode element)
+        {
+            foreach (var kvp in Components)
+            {
+                var component = kvp.Value;
+                if (TryGetElementFromChildren(component, out element))
+                {
+                    return true;
+                }
+            }
+
+            element = null;
+            return false;
+
+            bool TryGetElementFromChildren(Node node, out ElementNode foundNode)
+            {
+                if (node is ElementNode elementNode &&
+                    elementNode.Attributes.TryGetValue("id", out var elementId) &&
+                    elementId?.ToString() == id)
+                {
+                    foundNode = elementNode;
+                    return true;
+                }
+
+                if (node is ContainerNode containerNode)
+                {
+                    for (var i = 0; i < containerNode.Children.Count; i++)
+                    {
+                        if (TryGetElementFromChildren(containerNode.Children[i], out foundNode))
+                        {
+                            return true;
+                        }
+                    }
+                }
+
+                foundNode = null;
+                return false;
+            }
+        }
+
+        private void UpdateComponent(RenderBatch batch, int componentId, ArraySegment<RenderTreeEdit> edits)
+        {
+            if (!Components.TryGetValue(componentId, out var component))
+            {
+                component = new ComponentNode(componentId);
+                Components.Add(componentId, component);
+            }
+
+            ApplyEdits(batch, component, 0, edits);
+        }
+
+        private void DisposeComponent(int componentId)
+        {
+
+        }
+
+        private void DisposeEventHandler(int eventHandlerId)
+        {
+
+        }
+
+        private void ApplyEdits(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment<RenderTreeEdit> edits)
+        {
+            var currentDepth = 0;
+            var childIndexAtCurrentDepth = childIndex;
+            var permutations = new List<PermutationListEntry>();
+
+            for (var editIndex = edits.Offset; editIndex < edits.Offset + edits.Count; editIndex++)
+            {
+                var edit = edits.Array[editIndex];
+                switch (edit.Type)
+                {
+                    case RenderTreeEditType.PrependFrame:
+                        {
+                            var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
+                            var siblingIndex = edit.SiblingIndex;
+                            InsertFrame(batch, parent, childIndexAtCurrentDepth + siblingIndex, batch.ReferenceFrames.Array, frame, edit.ReferenceFrameIndex);
+                            break;
+                        }
+
+                    case RenderTreeEditType.RemoveFrame:
+                        {
+                            var siblingIndex = edit.SiblingIndex;
+                            parent.RemoveLogicalChild(childIndexAtCurrentDepth + siblingIndex);
+                            break;
+                        }
+
+                    case RenderTreeEditType.SetAttribute:
+                        {
+                            var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
+                            var siblingIndex = edit.SiblingIndex;
+                            var node = parent.Children[childIndexAtCurrentDepth + siblingIndex];
+                            if (node is ElementNode element)
+                            {
+                                ApplyAttribute(batch, element, frame);
+                            }
+                            else
+                            {
+                                throw new Exception("Cannot set attribute on non-element child");
+                            }
+                            break;
+                        }
+
+                    case RenderTreeEditType.RemoveAttribute:
+                        {
+                            // Note that we don't have to dispose the info we track about event handlers here, because the
+                            // disposed event handler IDs are delivered separately (in the 'disposedEventHandlerIds' array)
+                            var siblingIndex = edit.SiblingIndex;
+                            var node = parent.Children[childIndexAtCurrentDepth + siblingIndex];
+                            if (node is ElementNode element)
+                            {
+                                var attributeName = edit.RemovedAttributeName;
+
+                                // First try to remove any special property we use for this attribute
+                                if (!TryApplySpecialProperty(batch, element, attributeName, default))
+                                {
+                                    // If that's not applicable, it's a regular DOM attribute so remove that
+                                    element.RemoveAttribute(attributeName);
+                                }
+                            }
+                            else
+                            {
+                                throw new Exception("Cannot remove attribute from non-element child");
+                            }
+                            break;
+                        }
+
+                    case RenderTreeEditType.UpdateText:
+                        {
+                            var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
+                            var siblingIndex = edit.SiblingIndex;
+                            var node = parent.Children[childIndexAtCurrentDepth + siblingIndex];
+                            if (node is TextNode textNode)
+                            {
+                                textNode.TextContent = frame.TextContent;
+                            }
+                            else
+                            {
+                                throw new Exception("Cannot set text content on non-text child");
+                            }
+                            break;
+                        }
+
+
+                    case RenderTreeEditType.UpdateMarkup:
+                        {
+                            var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
+                            var siblingIndex = edit.SiblingIndex;
+                            parent.RemoveLogicalChild(childIndexAtCurrentDepth + siblingIndex);
+                            InsertMarkup(parent, childIndexAtCurrentDepth + siblingIndex, frame);
+                            break;
+                        }
+
+                    case RenderTreeEditType.StepIn:
+                        {
+                            var siblingIndex = edit.SiblingIndex;
+                            parent = (ContainerNode)parent.Children[childIndexAtCurrentDepth + siblingIndex];
+                            currentDepth++;
+                            childIndexAtCurrentDepth = 0;
+                            break;
+                        }
+
+                    case RenderTreeEditType.StepOut:
+                        {
+                            parent = parent.Parent;
+                            currentDepth--;
+                            childIndexAtCurrentDepth = currentDepth == 0 ? childIndex : 0; // The childIndex is only ever nonzero at zero depth
+                            break;
+                        }
+
+                    case RenderTreeEditType.PermutationListEntry:
+                        {
+                            permutations.Add(new PermutationListEntry(childIndexAtCurrentDepth + edit.SiblingIndex, childIndexAtCurrentDepth + edit.MoveToSiblingIndex));
+                            break;
+                        }
+
+                    case RenderTreeEditType.PermutationListEnd:
+                        {
+                            throw new NotSupportedException();
+                            //permuteLogicalChildren(parent, permutations!);
+                            //permutations.Clear();
+                            //break;
+                        }
+
+                    default:
+                        {
+                            throw new Exception($"Unknown edit type: '{edit.Type}'");
+                        }
+                }
+            }
+        }
+
+        private int InsertFrame(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment<RenderTreeFrame> frames, RenderTreeFrame frame, int frameIndex)
+        {
+            switch (frame.FrameType)
+            {
+                case RenderTreeFrameType.Element:
+                    {
+                        InsertElement(batch, parent, childIndex, frames, frame, frameIndex);
+                        return 1;
+                    }
+
+                case RenderTreeFrameType.Text:
+                    {
+                        InsertText(parent, childIndex, frame);
+                        return 1;
+                    }
+
+                case RenderTreeFrameType.Attribute:
+                    {
+                        throw new Exception("Attribute frames should only be present as leading children of element frames.");
+                    }
+
+                case RenderTreeFrameType.Component:
+                    {
+                        InsertComponent(parent, childIndex, frame);
+                        return 1;
+                    }
+
+                case RenderTreeFrameType.Region:
+                    {
+                        return InsertFrameRange(batch, parent, childIndex, frames, frameIndex + 1, frameIndex + CountDescendantFrames(frame));
+                    }
+
+                case RenderTreeFrameType.ElementReferenceCapture:
+                    {
+                        // No action for reference captures.
+                        break;
+                    }
+
+                case RenderTreeFrameType.Markup:
+                    {
+                        InsertMarkup(parent, childIndex, frame);
+                        return 1;
+                    }
+
+            }
+
+            throw new Exception($"Unknown frame type: {frame.FrameType}");
+        }
+
+        private void InsertText(ContainerNode parent, int childIndex, RenderTreeFrame frame)
+        {
+            var textContent = frame.TextContent;
+            var newTextNode = new TextNode(textContent);
+            parent.InsertLogicalChild(newTextNode, childIndex);
+        }
+
+        private void InsertComponent(ContainerNode parent, int childIndex, RenderTreeFrame frame)
+        {
+            // All we have to do is associate the child component ID with its location. We don't actually
+            // do any rendering here, because the diff for the child will appear later in the render batch.
+            var childComponentId = frame.ComponentId;
+            var containerElement = parent.CreateAndInsertComponent(childComponentId, childIndex);
+
+            Components[childComponentId] = containerElement;
+        }
+
+        private int InsertFrameRange(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment<RenderTreeFrame> frames, int startIndex, int endIndexExcl)
+        {
+            var origChildIndex = childIndex;
+            for (var index = startIndex; index < endIndexExcl; index++)
+            {
+                var frame = batch.ReferenceFrames.Array[index];
+                var numChildrenInserted = InsertFrame(batch, parent, childIndex, frames, frame, index);
+                childIndex += numChildrenInserted;
+
+                // Skip over any descendants, since they are already dealt with recursively
+                index += CountDescendantFrames(frame);
+            }
+
+            return (childIndex - origChildIndex); // Total number of children inserted
+        }
+
+        private void InsertElement(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment<RenderTreeFrame> frames, RenderTreeFrame frame, int frameIndex)
+        {
+            // Note: we don't handle SVG here
+            var newElement = new ElementNode(frame.ElementName);
+            parent.InsertLogicalChild(newElement, childIndex);
+
+            // Apply attributes
+            for (var i = frameIndex + 1; i < frameIndex + frame.ElementSubtreeLength; i++)
+            {
+                var descendantFrame = batch.ReferenceFrames.Array[i];
+                if (descendantFrame.FrameType == RenderTreeFrameType.Attribute)
+                {
+                    ApplyAttribute(batch, newElement, descendantFrame);
+                }
+                else
+                {
+                    // As soon as we see a non-attribute child, all the subsequent child frames are
+                    // not attributes, so bail out and insert the remnants recursively
+                    InsertFrameRange(batch, newElement, 0, frames, i, frameIndex + frame.ElementSubtreeLength);
+                    break;
+                }
+            }
+        }
+
+        private void ApplyAttribute(RenderBatch batch, ElementNode elementNode, RenderTreeFrame attributeFrame)
+        {
+            var attributeName = attributeFrame.AttributeName;
+            var eventHandlerId = attributeFrame.AttributeEventHandlerId;
+
+            if (eventHandlerId != 0)
+            {
+                var firstTwoChars = attributeName.Substring(0, 2);
+                var eventName = attributeName.Substring(2);
+                if (firstTwoChars != "on" || string.IsNullOrEmpty(eventName))
+                {
+                    throw new InvalidOperationException($"Attribute has nonzero event handler ID, but attribute name '${attributeName}' does not start with 'on'.");
+                }
+                var descriptor = new ElementNode.ElementEventDescriptor(eventName, eventHandlerId);
+                elementNode.SetEvent(eventName, descriptor);
+
+                return;
+            }
+
+            // First see if we have special handling for this attribute
+            if (!TryApplySpecialProperty(batch, elementNode, attributeName, attributeFrame))
+            {
+                // If not, treat it as a regular string-valued attribute
+                elementNode.SetAttribute(
+                  attributeName,
+                  attributeFrame.AttributeValue);
+            }
+        }
+
+        private bool TryApplySpecialProperty(RenderBatch batch, ElementNode element, string attributeName, RenderTreeFrame attributeFrame)
+        {
+            switch (attributeName)
+            {
+                case "value":
+                    return TryApplyValueProperty(element, attributeFrame);
+                case "checked":
+                    return TryApplyCheckedProperty(element, attributeFrame);
+                default:
+                    return false;
+            }
+        }
+
+
+
+        private bool TryApplyValueProperty(ElementNode element, RenderTreeFrame attributeFrame)
+        {
+            // Certain elements have built-in behaviour for their 'value' property
+            switch (element.TagName)
+            {
+                case "INPUT":
+                case "SELECT":
+                case "TEXTAREA":
+                    {
+                        var value = attributeFrame.AttributeValue;
+                        element.SetProperty("value", value);
+
+                        if (element.TagName == "SELECT")
+                        {
+                            // <select> is special, in that anything we write to .value will be lost if there
+                            // isn't yet a matching <option>. To maintain the expected behavior no matter the
+                            // element insertion/update order, preserve the desired value separately so
+                            // we can recover it when inserting any matching <option>.
+                            element.SetProperty(SelectValuePropname, value);
+                        }
+                        return true;
+                    }
+                case "OPTION":
+                    {
+                        var value = attributeFrame.AttributeValue;
+                        if (value != null)
+                        {
+                            element.SetAttribute("value", value);
+                        }
+                        else
+                        {
+                            element.RemoveAttribute("value");
+                        }
+                        return true;
+                    }
+                default:
+                    return false;
+            }
+        }
+
+        private bool TryApplyCheckedProperty(ElementNode element, RenderTreeFrame attributeFrame)
+        {
+            // Certain elements have built-in behaviour for their 'checked' property
+            if (element.TagName == "INPUT")
+            {
+                var value = attributeFrame.AttributeValue;
+                element.SetProperty("checked", value);
+                return true;
+            }
+
+            return false;
+        }
+
+        private void InsertMarkup(ContainerNode parent, int childIndex, RenderTreeFrame markupFrame)
+        {
+            var markupContainer = parent.CreateAndInsertContainer(childIndex);
+            var markupContent = markupFrame.MarkupContent;
+            var markupNode = new MarkupNode(markupContent);
+            markupContainer.InsertLogicalChild(markupNode, childIndex);
+        }
+
+        private int CountDescendantFrames(RenderTreeFrame frame)
+        {
+            switch (frame.FrameType)
+            {
+                // The following frame types have a subtree length. Other frames may use that memory slot
+                // to mean something else, so we must not read it. We should consider having nominal subtypes
+                // of RenderTreeFramePointer that prevent access to non-applicable fields.
+                case RenderTreeFrameType.Component:
+                    return frame.ComponentSubtreeLength - 1;
+                case RenderTreeFrameType.Element:
+                    return frame.ElementSubtreeLength - 1;
+                case RenderTreeFrameType.Region:
+                    return frame.RegionSubtreeLength - 1;
+                default:
+                    return 0;
+            }
+        }
+
+        private readonly struct PermutationListEntry
+        {
+            public readonly int From;
+            public readonly int To;
+
+            public PermutationListEntry(int from, int to)
+            {
+                From = from;
+                To = to;
+            }
+        }
+    }
+}

+ 106 - 0
src/Components/test/testassets/Ignitor/ElementNode.cs

@@ -0,0 +1,106 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Browser;
+using Microsoft.AspNetCore.SignalR.Client;
+
+namespace Ignitor
+{
+    internal class ElementNode : ContainerNode
+    {
+        private readonly Dictionary<string, object> _attributes;
+        private readonly Dictionary<string, object> _properties;
+        private readonly Dictionary<string, ElementEventDescriptor> _events;
+
+        public ElementNode(string tagName)
+        {
+            TagName = tagName ?? throw new ArgumentNullException(nameof(tagName));
+            _attributes = new Dictionary<string, object>(StringComparer.Ordinal);
+            _properties = new Dictionary<string, object>(StringComparer.Ordinal);
+            _events = new Dictionary<string, ElementEventDescriptor>(StringComparer.Ordinal);
+        }
+        public string TagName { get; }
+
+        public IReadOnlyDictionary<string, object> Attributes => _attributes;
+
+        public IReadOnlyDictionary<string, object> Properties => _properties;
+
+        public IReadOnlyDictionary<string, ElementEventDescriptor> Events => _events;
+
+        public void SetAttribute(string key, object value)
+        {
+            _attributes[key] = value;
+        }
+
+        public void RemoveAttribute(string key)
+        {
+            _attributes.Remove(key);
+        }
+
+        public void SetProperty(string key, object value)
+        {
+            _properties[key] = value;
+        }
+
+        public void SetEvent(string eventName, ElementEventDescriptor descriptor)
+        {
+            if (eventName is null)
+            {
+                throw new ArgumentNullException(nameof(eventName));
+            }
+
+            if (descriptor is null)
+            {
+                throw new ArgumentNullException(nameof(descriptor));
+            }
+
+            _events[eventName] = descriptor;
+        }
+
+        public class ElementEventDescriptor
+        {
+            public ElementEventDescriptor(string eventName, int eventId)
+            {
+                EventName = eventName ?? throw new ArgumentNullException(nameof(eventName));
+                EventId = eventId;
+            }
+
+            public string EventName { get; }
+
+            public int EventId { get; }
+        }
+
+        public async Task ClickAsync(HubConnection connection)
+        {
+            if (!Events.TryGetValue("click", out var clickEventDescriptor))
+            {
+                Console.WriteLine("Button does not have a click event. Exiting");
+                return;
+            }
+            var mouseEventArgs = new UIMouseEventArgs()
+            {
+                Type = clickEventDescriptor.EventName,
+                Detail = 1
+            };
+            var browserDescriptor = new RendererRegistryEventDispatcher.BrowserEventDescriptor()
+            {
+                BrowserRendererId = 0,
+                EventHandlerId = clickEventDescriptor.EventId,
+                EventArgsType = "mouse",
+            };
+            var serializedJson = JsonSerializer.ToString(mouseEventArgs, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+            var argsObject = new object[] { browserDescriptor, serializedJson };
+            var callId = "0";
+            var assemblyName = "Microsoft.AspNetCore.Components.Browser";
+            var methodIdentifier = "DispatchEvent";
+            var dotNetObjectId = 0;
+            var clickArgs = JsonSerializer.ToString(argsObject, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+            await connection.InvokeAsync("BeginInvokeDotNetFromJS", callId, assemblyName, methodIdentifier, dotNetObjectId, clickArgs);
+        }
+    }
+}

+ 16 - 0
src/Components/test/testassets/Ignitor/Ignitor.csproj

@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>netcoreapp3.0</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.SignalR.Client" />
+    <Reference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" />
+    <Reference Include="Microsoft.Extensions.Logging.Console" />
+    <Reference Include="Microsoft.AspNetCore.Components.Server" />
+    <Reference Include="System.Text.Json" />
+  </ItemGroup>
+
+</Project>

+ 12 - 0
src/Components/test/testassets/Ignitor/IgnitorMessagePackHubProtocol.cs

@@ -0,0 +1,12 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.SignalR.Protocol;
+
+namespace Ignitor
+{
+    public class IgnitorMessagePackHubProtocol : MessagePackHubProtocol, IHubProtocol
+    {
+        string IHubProtocol.Name => "blazorpack";
+    }
+}

+ 15 - 0
src/Components/test/testassets/Ignitor/MarkupNode.cs

@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Ignitor
+{
+    internal class MarkupNode : Node
+    {
+        public MarkupNode(string markupContent)
+        {
+            MarkupContent = markupContent;
+        }
+
+        public string MarkupContent { get; }
+    }
+}

+ 10 - 0
src/Components/test/testassets/Ignitor/Node.cs

@@ -0,0 +1,10 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Ignitor
+{
+    internal abstract class Node
+    {
+        public virtual ContainerNode Parent { get; set; }
+    }
+}

+ 196 - 0
src/Components/test/testassets/Ignitor/NodeSerializer.cs

@@ -0,0 +1,196 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+
+namespace Ignitor
+{
+    internal static class NodeSerializer
+    {
+        public static string Serialize(ElementHive hive)
+        {
+            using (var writer = new StringWriter())
+            {
+                var serializer = new Serializer(writer);
+                serializer.SerializeHive(hive);
+                return writer.ToString();
+            }
+        }
+
+        private class Serializer
+        {
+            private readonly TextWriter _writer;
+            private int _depth;
+            private bool _atStartOfLine;
+
+            public Serializer(TextWriter writer)
+            {
+                _writer = writer ?? throw new ArgumentNullException(nameof(writer));
+            }
+
+            public void SerializeHive(ElementHive hive)
+            {
+                foreach (var kvp in hive.Components)
+                {
+                    SerializeComponent(kvp.Key, kvp.Value);
+                }
+            }
+
+            private void Serialize(Node node)
+            {
+                switch (node)
+                {
+                    case ElementNode elementNode:
+                        {
+                            SerializeElement(elementNode);
+                            break;
+                        }
+                    case TextNode textNode:
+                        {
+                            SerializeTextNode(textNode);
+                            break;
+                        }
+                    case MarkupNode markupNode:
+                        {
+                            SerializeMarkupNode(markupNode);
+                            break;
+                        }
+                    case ContainerNode containerNode:
+                        {
+                            SerializeChildren(containerNode);
+                            break;
+                        }
+                    default:
+                        {
+                            Write("--- UNKNOWN (");
+                            Write(node.GetType().ToString());
+                            WriteLine(") ---");
+                            break;
+                        }
+                }
+            }
+
+            private void SerializeMarkupNode(MarkupNode markupNode)
+            {
+                Write("M: ");
+                WriteLine(markupNode.MarkupContent.Replace(Environment.NewLine, "\\r\\n"));
+            }
+
+            private void SerializeTextNode(TextNode textNode)
+            {
+                Write("T: ");
+                WriteLine(textNode.TextContent);
+            }
+
+            private void SerializeElement(ElementNode elementNode)
+            {
+                Write("<");
+                Write(elementNode.TagName);
+
+                foreach (var attribute in elementNode.Attributes)
+                {
+                    Write(" ");
+                    Write(attribute.Key);
+
+                    if (attribute.Value != null)
+                    {
+                        Write("=\"");
+                        Write(attribute.Value.ToString());
+                        Write("\"");
+                    }
+                }
+
+                if (elementNode.Properties.Count > 0)
+                {
+                    Write("  Properties: [");
+
+                    foreach (var properties in elementNode.Properties)
+                    {
+                        Write(" ");
+                        Write(properties.Key);
+
+                        if (properties.Value != null)
+                        {
+                            Write("=\"");
+                            Write(properties.Value.ToString());
+                            Write("\"");
+                        }
+                    }
+                    Write("]");
+                }
+
+                if (elementNode.Events.Count > 0)
+                {
+                    Write("  Events: [");
+
+                    foreach (var evt in elementNode.Events)
+                    {
+                        Write(" ");
+                        Write(evt.Value.EventName);
+                        Write("(");
+                        Write(evt.Value.EventId.ToString());
+                        Write(")");
+                    }
+                    Write("]");
+                }
+
+                WriteLine(">");
+
+                _depth++;
+                SerializeChildren(elementNode);
+                _depth--;
+                Write("</");
+                Write(elementNode.TagName);
+                WriteLine("/>");
+            }
+
+            private void SerializeChildren(ContainerNode containerNode)
+            {
+                for (var i = 0; i < containerNode.Children.Count; i++)
+                {
+                    Serialize(containerNode.Children[i]);
+                }
+            }
+
+            private void SerializeComponent(int id, ComponentNode component)
+            {
+                Write("[Component ( ");
+                Write(id.ToString());
+                WriteLine(" )]");
+                _depth++;
+                SerializeChildren(component);
+                _depth--;
+            }
+
+            private void Write(string content)
+            {
+                if (_atStartOfLine)
+                {
+                    WriteIndent();
+                }
+
+                _writer.Write(content);
+
+                _atStartOfLine = false;
+            }
+
+            private void WriteLine(string content)
+            {
+                if (_atStartOfLine)
+                {
+                    WriteIndent();
+                }
+
+                _writer.WriteLine(content);
+                _atStartOfLine = true;
+            }
+
+            private void WriteIndent()
+            {
+                var indent = new string(' ', _depth * 4);
+                _writer.Write(indent);
+            }
+        }
+    }
+}

+ 142 - 0
src/Components/test/testassets/Ignitor/Program.cs

@@ -0,0 +1,142 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.SignalR.Client;
+using Microsoft.AspNetCore.SignalR.Protocol;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+
+namespace Ignitor
+{
+    internal class Program
+    {
+        public static async Task<int> Main(string[] args)
+        {
+            if (args.Length == 0)
+            {
+                Console.WriteLine("a uri is required");
+                return 1;
+            }
+
+            Console.WriteLine("Press the ANY key to begin.");
+            Console.ReadLine();
+
+            var uri = new Uri(args[0]);
+
+            var program = new Program();
+            Console.CancelKeyPress += (sender, e) => { program.Cancel(); };
+
+            await program.ExecuteAsync(uri);
+            return 0;
+        }
+
+        public Program()
+        {
+            CancellationTokenSource = new CancellationTokenSource();
+            TaskCompletionSource = new TaskCompletionSource<object>();
+
+            CancellationTokenSource.Token.Register(() =>
+            {
+                TaskCompletionSource.TrySetCanceled();
+            });
+        }
+
+        private CancellationTokenSource CancellationTokenSource { get; }
+        private CancellationToken CancellationToken => CancellationTokenSource.Token;
+        private TaskCompletionSource<object> TaskCompletionSource { get; }
+
+        public async Task ExecuteAsync(Uri uri)
+        {
+            var httpClient = new HttpClient();
+            var response = await httpClient.GetAsync(uri);
+            var content = await response.Content.ReadAsStringAsync();
+
+            // <!-- M.A.C.Component:{"circuitId":"CfDJ8KZCIaqnXmdF...PVd6VVzfnmc1","rendererId":"0","componentId":"0"} -->
+            var match = Regex.Match(content, $"{Regex.Escape("<!-- M.A.C.Component:")}(.+?){Regex.Escape(" -->")}");
+            var json = JsonDocument.Parse(match.Groups[1].Value);
+            var circuitId = json.RootElement.GetProperty("circuitId").GetString();
+
+            var builder = new HubConnectionBuilder();
+            builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IHubProtocol, IgnitorMessagePackHubProtocol>());
+            builder.WithUrl(new Uri(uri, "_blazor/"));
+            builder.ConfigureLogging(l => l.AddConsole().SetMinimumLevel(LogLevel.Trace));
+            var hive = new ElementHive();
+
+            await using var connection = builder.Build();
+            await connection.StartAsync(CancellationToken);
+            Console.WriteLine("Connected");
+
+            connection.On<int, string, string>("JS.BeginInvokeJS", OnBeginInvokeJS);
+            connection.On<int, int, byte[]>("JS.RenderBatch", OnRenderBatch);
+            connection.On<Error>("JS.OnError", OnError);
+            connection.Closed += OnClosedAsync;
+
+            // Now everything is registered so we can start the circuit.
+            var success = await connection.InvokeAsync<bool>("ConnectCircuit", circuitId);
+
+            await TaskCompletionSource.Task;
+
+            void OnBeginInvokeJS(int asyncHandle, string identifier, string argsJson)
+            {
+                Console.WriteLine("JS Invoke: " + identifier + " (" + argsJson + ")");
+            }
+
+            void OnRenderBatch(int browserRendererId, int batchId, byte[] batchData)
+            {
+                var batch = RenderBatchReader.Read(batchData);
+                hive.Update(batch);
+
+                // This will click the Counter component repeatedly resulting in infinite requests.
+                _ = ClickAsync("thecounter", hive, connection);
+            }
+
+            void OnError(Error error)
+            {
+                Console.WriteLine("ERROR: " + error.Stack);
+            }
+
+            Task OnClosedAsync(Exception ex)
+            {
+                if (ex == null)
+                {
+                    TaskCompletionSource.TrySetResult(null);
+                }
+                else
+                {
+                    TaskCompletionSource.TrySetException(ex);
+                }
+
+                return Task.CompletedTask;
+            }
+        }
+
+        private static async Task ClickAsync(string id, ElementHive hive, HubConnection connection)
+        {
+            if (!hive.TryFindElementById(id, out var elementNode))
+            {
+                Console.WriteLine("Could not find the counter to perform a click. Exiting.");
+                return;
+            }
+
+            await elementNode.ClickAsync(connection);
+        }
+
+        public void Cancel()
+        {
+            CancellationTokenSource.Cancel();
+            CancellationTokenSource.Dispose();
+        }
+
+        private class Error
+        {
+            public string Stack { get; set; }
+        }
+    }
+}

+ 3 - 0
src/Components/test/testassets/Ignitor/Properties/AssemblyInfo.cs

@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Ignitor.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

+ 307 - 0
src/Components/test/testassets/Ignitor/RenderBatchReader.cs

@@ -0,0 +1,307 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Ignitor
+{
+    public static class RenderBatchReader
+    {
+        private static readonly Renderer Renderer = new FakeRenderer();
+
+        public static RenderBatch Read(ReadOnlySpan<byte> data)
+        {
+            var sections = Sections.Parse(data);
+            var strings = ReadStringTable(data, sections.GetStringTableIndexes(data));
+            var diffs = ReadUpdatedComponents(data, sections.GetUpdatedComponentIndexes(data), strings);
+            var frames = ReadReferenceFrames(sections.GetReferenceFrameData(data), strings);
+            var disposedComponentIds = ReadDisposedComponentIds(data);
+            var disposedEventHandlerIds = ReadDisposedEventHandlerIds(data);
+            return new RenderBatch(diffs, frames, disposedComponentIds, disposedEventHandlerIds);
+        }
+
+        private static string[] ReadStringTable(ReadOnlySpan<byte> data, ReadOnlySpan<byte> indexes)
+        {
+            var result = new string[indexes.Length / 4];
+
+            for (var i = 0; i < indexes.Length; i += 4)
+            {
+                var index = BitConverter.ToInt32(indexes.Slice(i, 4));
+
+                // The string table entries are all length-prefixed UTF8 blobs
+                var length = (int)ReadUnsignedLEB128(data, index, out var numLEB128Bytes);
+                var value = Encoding.UTF8.GetString(data.Slice(index + numLEB128Bytes, length));
+                result[i / 4] = value;
+            }
+
+            return result;
+        }
+
+        private static ArrayRange<RenderTreeDiff> ReadUpdatedComponents(ReadOnlySpan<byte> data, ReadOnlySpan<byte> indexes, string[] strings)
+        {
+            var result = new RenderTreeDiff[indexes.Length / 4];
+
+            for (var i = 0; i < indexes.Length; i += 4)
+            {
+                var index = BitConverter.ToInt32(indexes.Slice(i, 4));
+
+                var componentId = BitConverter.ToInt32(data.Slice(index, 4));
+                var editCount = BitConverter.ToInt32(data.Slice(index + 4, 4));
+
+                var editData = data.Slice(index + 8);
+                var edits = new RenderTreeEdit[editCount];
+                for (var j = 0; j < editCount; j++)
+                {
+                    var type = (RenderTreeEditType)BitConverter.ToInt32(editData.Slice(0, 4));
+                    var siblingIndex = BitConverter.ToInt32(editData.Slice(4, 4));
+
+                    // ReferenceFrameIndex and MoveToSiblingIndex share a slot, so this reads
+                    // whichever one applies to the edit type
+                    var referenceFrameIndex = BitConverter.ToInt32(editData.Slice(8, 4));
+                    var removedAttributeName = ReadString(editData.Slice(12, 4), strings);
+
+                    editData = editData.Slice(16);
+
+                    switch (type)
+                    {
+                        case RenderTreeEditType.UpdateText:
+                            edits[j] = RenderTreeEdit.UpdateText(siblingIndex, referenceFrameIndex);
+                            break;
+
+                        case RenderTreeEditType.UpdateMarkup:
+                            edits[j] = RenderTreeEdit.UpdateMarkup(siblingIndex, referenceFrameIndex);
+                            break;
+
+                        case RenderTreeEditType.SetAttribute:
+                            edits[j] = RenderTreeEdit.SetAttribute(siblingIndex, referenceFrameIndex);
+                            break;
+
+                        case RenderTreeEditType.RemoveAttribute:
+                            edits[j] = RenderTreeEdit.RemoveAttribute(siblingIndex, removedAttributeName);
+                            break;
+
+                        case RenderTreeEditType.PrependFrame:
+                            edits[j] = RenderTreeEdit.PrependFrame(siblingIndex, referenceFrameIndex);
+                            break;
+
+                        case RenderTreeEditType.RemoveFrame:
+                            edits[j] = RenderTreeEdit.RemoveFrame(siblingIndex);
+                            break;
+
+                        case RenderTreeEditType.StepIn:
+                            edits[j] = RenderTreeEdit.StepIn(siblingIndex);
+                            break;
+
+                        case RenderTreeEditType.StepOut:
+                            edits[j] = RenderTreeEdit.StepOut();
+                            break;
+
+                        case RenderTreeEditType.PermutationListEntry:
+                            edits[j] = RenderTreeEdit.PermutationListEntry(siblingIndex, referenceFrameIndex);
+                            break;
+
+                        case RenderTreeEditType.PermutationListEnd:
+                            edits[j] = RenderTreeEdit.PermutationListEnd();
+                            break;
+
+                        default:
+                            throw new InvalidOperationException("Unknown edit type:" + type);
+                    }
+                }
+
+                result[i / 4] = new RenderTreeDiff(componentId, new ArraySegment<RenderTreeEdit>(edits));
+            }
+
+            return new ArrayRange<RenderTreeDiff>(result, result.Length);
+        }
+
+        private static ArrayRange<RenderTreeFrame> ReadReferenceFrames(ReadOnlySpan<byte> data, string[] strings)
+        {
+            var result = new RenderTreeFrame[data.Length / 16];
+
+            for (var i = 0; i < data.Length; i += 16)
+            {
+                var frameData = data.Slice(i, 16);
+
+                var type = (RenderTreeFrameType)BitConverter.ToInt32(frameData.Slice(0, 4));
+
+                // We want each frame to take up the same number of bytes, so that the
+                // recipient can index into the array directly instead of having to
+                // walk through it.
+                // Since we can fit every frame type into 3 ints, use that as the
+                // common size. For smaller frames, we add padding to expand it to
+                // 12 bytes (i.e., 3 x 4-byte ints).
+                // The total size then for each frame is 16 bytes (frame type, then
+                // 3 other ints).
+                switch (type)
+                {
+                    case RenderTreeFrameType.Attribute:
+                        var attributeName = ReadString(frameData.Slice(4, 4), strings);
+                        var attributeValue = ReadString(frameData.Slice(8, 4), strings);
+                        var attributeEventHandlerId = BitConverter.ToInt32(frameData.Slice(12, 4));
+                        result[i / 16] = RenderTreeFrame.Attribute(0, attributeName, attributeValue).WithAttributeEventHandlerId(attributeEventHandlerId);
+                        break;
+
+                    case RenderTreeFrameType.Component:
+                        var componentSubtreeLength = BitConverter.ToInt32(frameData.Slice(4, 4));
+                        var componentId = BitConverter.ToInt32(frameData.Slice(8, 4)); // Nowhere to put this without creating a ComponentState
+                        result[i / 16] = RenderTreeFrame.ChildComponent(0, componentType: null)
+                            .WithComponentSubtreeLength(componentSubtreeLength)
+                            .WithComponent(new ComponentState(Renderer, componentId, new FakeComponent(), null));
+                        break;
+
+                    case RenderTreeFrameType.ComponentReferenceCapture:
+                        // Client doesn't process these, skip.
+                        result[i / 16] = RenderTreeFrame.ComponentReferenceCapture(0, null, 0);
+                        break;
+
+                    case RenderTreeFrameType.Element:
+                        var elementSubtreeLength = BitConverter.ToInt32(frameData.Slice(4, 4));
+                        var elementName = ReadString(frameData.Slice(8, 4), strings);
+                        result[i / 16] = RenderTreeFrame.Element(0, elementName).WithElementSubtreeLength(elementSubtreeLength);
+                        break;
+
+                    case RenderTreeFrameType.ElementReferenceCapture:
+                        var referenceCaptureId = ReadString(frameData.Slice(4, 4), strings);
+                        result[i / 16] = RenderTreeFrame.ElementReferenceCapture(0, null)
+                            .WithElementReferenceCaptureId(referenceCaptureId);
+                        break;
+
+                    case RenderTreeFrameType.Region:
+                        var regionSubtreeLength = BitConverter.ToInt32(frameData.Slice(4, 4));
+                        result[i / 16] = RenderTreeFrame.Region(0).WithRegionSubtreeLength(regionSubtreeLength);
+                        break;
+
+                    case RenderTreeFrameType.Text:
+                        var text = ReadString(frameData.Slice(4, 4), strings);
+                        result[i / 16] = RenderTreeFrame.Text(0, text);
+                        break;
+
+                    case RenderTreeFrameType.Markup:
+                        var markup = ReadString(frameData.Slice(4, 4), strings);
+                        result[i / 16] = RenderTreeFrame.Markup(0, markup);
+                        break;
+
+                    default:
+                        throw new ArgumentException($"Unsupported frame type: {type}");
+                }
+            }
+
+            return new ArrayRange<RenderTreeFrame>(result, result.Length);
+        }
+
+        private static ArrayRange<int> ReadDisposedComponentIds(ReadOnlySpan<byte> data)
+        {
+            return new ArrayRange<int>(Array.Empty<int>(), 0);
+        }
+
+        private static ArrayRange<int> ReadDisposedEventHandlerIds(ReadOnlySpan<byte> data)
+        {
+            return new ArrayRange<int>(Array.Empty<int>(), 0);
+        }
+
+        private static string ReadString(ReadOnlySpan<byte> data, string[] strings)
+        {
+            var index = BitConverter.ToInt32(data.Slice(0, 4));
+            return index >= 0 ? strings[index] : null;
+        }
+
+        private static uint ReadUnsignedLEB128(ReadOnlySpan<byte> data, int startOffset, out int numBytesRead)
+        {
+            var result = (uint)0;
+            var shift = 0;
+            var currentByte = (byte)128;
+            numBytesRead = 0;
+
+            for (var count = 0; count < 4 && currentByte >= 128; count++)
+            {
+                currentByte = data[startOffset + count];
+                result += (uint)(currentByte & 0x7f) << shift;
+                shift += 7;
+                numBytesRead++;
+            }
+
+            return result;
+        }
+
+        private readonly struct Sections
+        {
+            public static Sections Parse(ReadOnlySpan<byte> data)
+            {
+                return new Sections(
+                    BitConverter.ToInt32(data.Slice(data.Length - 20, 4)),
+                    BitConverter.ToInt32(data.Slice(data.Length - 16, 4)),
+                    BitConverter.ToInt32(data.Slice(data.Length - 12, 4)),
+                    BitConverter.ToInt32(data.Slice(data.Length - 8, 4)),
+                    BitConverter.ToInt32(data.Slice(data.Length - 4, 4)));
+            }
+
+            private readonly int _updatedComponents;
+            private readonly int _referenceFrames;
+            private readonly int _disposedComponentIds;
+            private readonly int _disposedEventHandlerIds;
+            private readonly int _strings;
+
+            public Sections(int updatedComponents, int referenceFrames, int disposedComponentIds, int disposedEventHandlerIds, int strings)
+            {
+                _updatedComponents = updatedComponents;
+                _referenceFrames = referenceFrames;
+                _disposedComponentIds = disposedComponentIds;
+                _disposedEventHandlerIds = disposedEventHandlerIds;
+                _strings = strings;
+            }
+
+            public ReadOnlySpan<byte> GetUpdatedComponentIndexes(ReadOnlySpan<byte> data)
+            {
+                // This is count-prefixed contiguous array of of integers.
+                var count = BitConverter.ToInt32(data.Slice(_updatedComponents, 4));
+                return data.Slice(_updatedComponents + 4, count * 4);
+            }
+
+            public ReadOnlySpan<byte> GetReferenceFrameData(ReadOnlySpan<byte> data)
+            {
+                // This is a count-prefixed contiguous array of RenderTreeFrame.
+                var count = BitConverter.ToInt32(data.Slice(_referenceFrames, 4));
+                return data.Slice(_referenceFrames + 4, count * 16);
+            }
+
+            public ReadOnlySpan<byte> GetStringTableIndexes(ReadOnlySpan<byte> data)
+            {
+                // This is a contiguous array of integers delimited by the end of the data section.
+                return data.Slice(_strings, data.Length - 20 - _strings);
+            }
+        }
+
+        public class FakeRenderer : Renderer
+        {
+            public FakeRenderer()
+                : base(new ServiceCollection().BuildServiceProvider(), new RendererSynchronizationContext())
+            {
+            }
+
+            protected override void HandleException(Exception exception)
+            {
+                throw new NotImplementedException();
+            }
+
+            protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
+                => throw new NotImplementedException();
+        }
+
+
+        public class FakeComponent : IComponent
+        {
+            public void Configure(RenderHandle renderHandle)
+                => throw new NotImplementedException();
+
+            public Task SetParametersAsync(ParameterCollection parameters)
+                => throw new NotImplementedException();
+        }
+    }
+}

+ 15 - 0
src/Components/test/testassets/Ignitor/TextNode.cs

@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Ignitor
+{
+    internal class TextNode : Node
+    {
+        public TextNode(string text)
+        {
+            TextContent = text;
+        }
+
+        public string TextContent { get; set; }
+    }
+}