tbc facilitates patch-based c# hot reload for people who like hard work (and dependency injection, probably).
it's alpha quality by me for me
now with maui powers (here's an old video showing more stuff)
-
configurable c# 'hot reload': patch new types into the running application - new views, viewmodels, services or whatever you can work into the constraints of your application or framework's architecture
-
arbitrary debug/dev commands: define commands in your application and they can be invoked externally to modify or introspect the application. for example, 'goto to screen', 'log out', 'clear cache', 'list cache'
-
debuggable reloaded code: set a breakpoint in code you just reloaded and it will be hit
-
reloadable reloading: your customised reload implementation and command handler can be reloaded and debugged too.
- 'host' watches for changes in your project
- 'host' compiles the incremental changes you make into teeny tiny assemblies
- 'host' send the assemblies to your running app (the 'target'), which calls to your
IReloadManager
implementation
- actually integrate your changes or perform any kind of reload (that's your job - implement
ProcessNewAssembly
and make magic) - any of fairly typical quality of life things you might expect from a hot reload framework - service discovery, an ide plugin, etc.
- respond well to changing static types, extension methods etc. basically if you work outside the known constraints things will probably stop working (but you can use
tree remove
to exclude things.. see below)
this sounds lame, magic method replacement is the past, present, and future of hot reload! also i want an ide plugin!
Yes you're probably right - you can try LiveSharp which is more mature, very configurable, and by now pretty stable (especially for non-Mono workloads). Or you can wait for official dotnet stuff. For another 'type replacement' based reloader you can check out Clancey's Reloadify 3000, which does have an IDE plugin and service discovery and is generally going to be easier to use.
tbc will probably always have rough edges/require 'hard work' because an official solution based on a superior implementation is 'coming soon'; it's just a stopgap I have hacked on a bit every time I started a new app because I still need to build things now. If you also need to build things now and don't mind getting hands dirty then tbc could help you too.
-
install a tbc 'host' e.g.
tbc.host.console
:dotnet tool install -g tbc.console
-
add the tbc 'target' package to your reloadable app (
tbc.target
) -
provide an implementation of
IReloadManager
(tip: derive fromReloadManagerBase
)- implement
ProcessNewAssembly
to integrate new types and perform any reload/navigation/recall of changed classes - implement
ExecuteCommand
to handle arbitrary commands you might want to issue to the running application
you can see an example of a basic prism reloader in the samples.
- implement
-
somewhere near startup, put something like:
Task.Run(async () => { var reloadManager = ... ; // create or resolve your IReloadManager var reloadService = new TargetServer(TargetConfiguration.Default()); await reloadService.Run(reloadManager); });
-
run
tbc
from the root of your solution, optionally with areload-config.json
file. see the video above, I like to run it from the IDE's integrated terminal -
start making changes! (save to update)
[*1] right now there is only a console host that watches addresses specified by config, but you could imagine an ide extension replicating the same small set of user interface functionality but with broadcast etc. You could use tbc.host.console
to hot reload building tbc.host.visualstudio
or tbc.host.vs4mac
(kinda like here).
ProcessNewAssembly
is called every time the host stages a new change. You are given a new assembly that contains a new version of all the types changed since the reloadable app started running (or, since the last reset
command was issued to the host). To 'hot reload' you typically need to perform two steps:
- Integrate changes
- 'Reload'
To integrate changes you should reflect over the types in the assembly and work them into the running app. For example, in a XF Prism app this would involve registering new services, pages and viewmodels into the container, which you could do in the below manner:
(note that in ProcessNewAssembly
you receive an already loaded assembly - no need to load from bytes)
Reloading usually amounts to updating the screen with changes you have made. An easy way to achieve this is to navigate to the new type that was introduced. In the Prism example (given you registered the page already) that would involve invoking the navigator as normal. Navigating without animating will give the appearance of instantaneous reload - hurrah, you just made a hot reload!
Even though tbc isn't truly changing types, with full control over the reload process you can also approximate more sophisticated integrations. For example, you could preserve state between changes by passing a viewmodel between new iterations of a page (or, if the viewmodel is also being reloaded, reflection properties from the old instance onto the new instance). Or, you could replace the cell template on the currently displayed screen if the relevant cell type was part of the reloaded assembly.
The host may provide a hint as to which type in a new assembly is intended to be the 'primary' one via the PrimaryType
property. In an application with a UI, you might then make sure the primary type is the one displayed on the screen after reloading. Or, you could ignore the type hint and instead interrogate the current app state (e.g. check what screen is currently being displayed) to decide what the best action to take is.
tbc includes the concept of 'commands' which can be issued either to the host or the target. When using the console host, you can type commands directly into the console to send them.
The host supports a small set of commands that were useful to me:
primary {hint}
: specify that one of the types in the staged changes should be the 'primary' type. You can provide just a hint of the type name and the host will attempt to match (e.g. primary home
will resolve the HomePage
class if it is part of the staged changes)
trees
: prints the set of staged files and contained types. Useful for seeing what incremental is being tracked.
tree remove {hint}
: removes a file from the staged set. Useful if you change a file that doesn't play nice with tbc (e.g. a static class with extension methods) and want to un-break reload without having to restart things.
reset
: resets the state of the incremental compiler - removes all trees and references, and asks the target to resend dependencies.
watch {path}
: relative to the last changed file's directory, recursively finds all .cs in at path
and below and adds them to the incremental compilation. e.g. watch .
to include all files in the same directory as the current file being changed, without having to trigger changes against them all individually
Commands prefixed with '!' will be sent to targets rather than the host. There are no built-in commands supported by the target, but you can add support for arbitrary commands to your IReloadManager
by implementing the ExecuteCommand
method. For example, a goto
command in a prism app could be implemented using the code below, which walks the service container for pages and allows them to be chosen from a menu (if not specified as an argument to the command):
(this command exists in the prism sample)
You could implement other useful debug commands for your application, like login/logout, cache clearing etc.
Since your IReloadManager
is itself reloadable (provided it derives from ReloadManagerBase
), you can add support for new commands while the application is still running. See here in the prism sample for how you can make the reloader reloadable.
Since the incremental compiler builds directly off the source files you're working on, debugging reloaded code is possible. Nice! VS for Mac seems to like to show the break higher in the callstack (at the first non-reloaded component), but you can select the current frame. Rider breaks in the expected place.
Tested only on the latest Mvvm Community Toolkit preview, you might be able to use source generators with tbc. This is configured by adding a SourceGeneratorReferences
array to the
AssemblyCompiler
configuration element. Here you can include references to dlls, nuget packages or csproj files.
"SourceGeneratorReferences": [
{
"Kind": "AssemblyPath",
"Reference": "/Users/rdavis/.nuget/packages/communitytoolkit.mvvm/8.0.0-preview3/analyzers/dotnet/roslyn4.0/cs/CommunityToolkit.Mvvm.SourceGenerators.dll"
},
{
"Kind": "NuGetPackageReference",
"Reference": "CommunityToolkit.Mvvm",
"Context": "8.0.0-preview3"
},
{
"Kind": "Csproj",
"Reference": "/Users/rdavis/Source/MyAppWithSourceGenerators/App1/App1/App1.iOS/App1.iOS.csproj"
},
]
- For an
AssemblyPath
reference, tbc will try to load the assembly and take anyISourceGenerator
andIIncrementalGenerator
types it can instantiate - For a
NuGetPackageReference
reference, tbc will scan the local nuget package cache for the provided package/version folder and to try to find assemblies that might contain generators, then pass them to theAssemblyPath
method - For a
Csproj
reference, tbc will parse the provided csproj file for nuget package references, then pass them to theNuGetPackageReference
method.
You can configure global usings by adding a GlobalUsingsSources
array to the AssemblyCompiler
configuration element.
Here you can include 'string lists' and/or search paths.
"GlobalUsingsSources": [
{
"Kind": "Text",
"Reference": "My.Namespace.A;My.Namespace.B"
},
{
"Kind": "SearchPath",
"Reference": "/Users/rdavis/Source/MyAppWithGlobalUsings/App1/App1/obj/Debug/",
"Context": "LastModified"
},
]
- For a
Text
source, tbc will split on ';' and add all the entries as usings - For a
SearchPath
reference, tbc will scan the provided search path for*.GlobalUsings.g.cs
. IfContext
is "LastModified
" or not specified, tbc will pick the most recently updated file of the files found. IfContext
is "Merge
", tbc will merge the contents of the files found.
tbc (host) has the concept of 'compilation fixers' that may run after a failed incremental compilation to try to make it not fail.
Compilation fixers can make a transient update to the compilation bundle in order to address issues that prevent compilation. For example, the AmbiguousInvocationFixer
targets CS121
and renames reloaded extension methods so that they are distinct from the originally compiled extension method. Others might be added in the future. To enable fixers, add
"FixerOptions": { "Enabled" : true }
to the AssemblyCompiler
configuration element.
Reloading types that derive from NSObject
will result in errors when the runtime attempts to register new versions of types with previously registered native keys. tbc can automatically add unique register attributes to all
NSObject
-derived types, in order to prevent clashes. To enable this, add
"iOSDynamicRegistrationOptions": { "Enabled": true }
to the AssemblyCompiler
configuration element. If you're not reloading NSObject
-based types, leave it off.
I've only used this for myself but on several production-complexity-level apps. I've only used it heavily on iOS. At least the sample works on Android too.
Your mileage may vary. Messing with static classes might work (if you enable fixers 🤠). Xaml files won't work (delete them 🤠🤠). Something that needs to be source generated might work with some effort (see source generators).
This used to use grpc.core for message interchange but it was not apple silicon friendly. I replaced grpc with a socket-based transport which hasn't yet had a huge amount of testing. But now it's apple silicon friendly and with .NET maui, the simulator is apple silicon friendly too! Finally nirvana.