GooPanel.cs
using System.Collections.Generic;
using Sandbox;
using Sandbox.UI;

namespace Goo;

public abstract class GooPanel<TRoot> : PanelComponent, IHotloadManaged where TRoot : struct, IBlob
{
    Fiber? _fiber;
    readonly BuildContext _ctx = new();
    bool _needsBuild = true;

    protected abstract TRoot Build();

    public void Rebuild() => _needsBuild = true;

    /// <summary>Override to drive per-frame state (animations, polled input).
    /// Return true while a rebuild is still needed next frame (e.g. an animation is in motion).
    /// Runs every frame, before the build gate.</summary>
    protected virtual bool Tick(float dt) => false;

    /// <summary>When true (default), a rebuild is requested automatically after any event
    /// handler on this panel's tree fires. Set false to restore fully manual Rebuild() control.</summary>
    protected virtual bool AutoRebuildOnEvents => true;

    // Hotload hook. Engine creates a fresh instance per Component on hotload, then
    // Cecil-migrates field values from the prior instance. We need to trigger a diff
    // against the new Build() output so the panel reflects edited source.
    void IHotloadManaged.Created(IReadOnlyDictionary<string, object> state) => _needsBuild = true;

    protected override void OnEnabled()
    {
        // Engine destroys Panel on disable and creates a fresh empty one on enable.
        // Drop our fiber so the next diff is a full mount against the new Panel.
        _fiber = null;
        _needsBuild = true;
    }

    protected override void OnUpdate()
    {
        if (Panel == null) return;

        // Per-frame state advances every frame, independent of the build gate.
        if (Tick(Time.Delta)) _needsBuild = true;
        if (!_needsBuild) return;

        var prev = BuildContext._current;
        BuildContext._current = _ctx;
        _ctx.RootRebuild = Rebuild;
        _ctx.AutoRebuildOnEvents = AutoRebuildOnEvents;
        try
        {
            TRoot next = Build();
            var result = Reconciler.Diff(ref _fiber, in next);
            foreach (var w in result.Warnings) Log.Warning(w);
            Applier.Apply(Panel, result.Ops);
        }
        finally
        {
            _ctx.ReturnAll();
            BuildContext._current = prev;
            _needsBuild = false;
        }
    }

    // Goo manages Panel.Children directly via Diff/Apply; skip Razor's render-tree build path.
    protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
    {
    }
}