private/Controls/Add-UiDataGridRowBackground.ps1

function Add-UiDataGridRowBackground {
    <#
    .SYNOPSIS
        Colors rows via -RowBackground, evaluated once per item so scroll stays fast.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Windows.Controls.DataGrid]$DataGrid,

        [Parameter(Mandatory)]
        [scriptblock]$RowBackground
    )

    # Sort/filter don't touch the source, so a scrolled row is a dictionary lookup instead of rerunning the scriptblock.
    # Matters once -RowBackground is anything heavier than a property read.
    $rowBgRef = $RowBackground
    $brushMap = [System.Collections.Generic.Dictionary[object,object]]::new()

    $evaluate = {
        param($item)
        try {
            # InvokeWithContext, not positional invoke - $_ doesn't survive the trip across the module boundary by itself (same fix as Add-UiDataGridRowDetails). Last output wins, like a return.
            $vars = [System.Collections.Generic.List[psvariable]]::new()
            $vars.Add([psvariable]::new('_', $item))
            $vars.Add([psvariable]::new('row', $item))
            $result = $rowBgRef.InvokeWithContext($null, $vars)
            $value  = if ($result.Count -gt 0) { $result[$result.Count - 1] } else { $null }
            if ($null -ne $value) {
                if ($value -is [System.Windows.Media.Brush]) { return $value }
                return (ConvertTo-UiBrush $value)
            }
        }
        catch { Write-Debug "RowBackground scriptblock failed: $_" }
        return $null
    }.GetNewClosure()

    # nvoke-UiAction and the Toggle cell both call this once the write lands
    $invalidate = {
        param($items)
        if ($null -eq $items) { $brushMap.Clear(); return }
        foreach ($target in @($items)) {
            if ($null -eq $target) { continue }
            [void]$brushMap.Remove($target)

            # Recolor the row here and now. Dropping the key only helps rows WPF regenerates afterwards and a checkbox click regenerates nothing.
            $container = $DataGrid.ItemContainerGenerator.ContainerFromItem($target)
            if (!$container) { continue }
            $brush = & $evaluate $target
            $brushMap[$target] = $brush
            if ($null -ne $brush) { $container.Background = $brush }
            else { $container.ClearValue([System.Windows.Controls.Control]::BackgroundProperty) }
        }
    }.GetNewClosure()

    if ($DataGrid.Tag -is [hashtable]) { $DataGrid.Tag.InvalidateRowBackground = $invalidate }

    # Track the live source so subscriptions swap when ItemsSource is replaced at runtime.
    # LoadingRow handles initial fill lazily, no eager prefill.
    $subState = @{ Source = $null; Handler = $null }

    $DataGrid.Add_LoadingRow({
        param($sender, $eventArgs)
        $row  = $eventArgs.Row
        $item = $row.Item

        if ($null -eq $item) {
            $row.ClearValue([System.Windows.Controls.Control]::BackgroundProperty)
            return
        }

        # Virtualization recycle reuses the brush already computed for this row.
        # Rare case when a newly added row whose CollectionChanged subscriber hasn't run yet (WPF can race the layout pass ahead of the multicast on -ItemsSource), or a source that doesn't implement INotifyCollectionChanged so the subscirber never runs. Compute inline and cache so the next time the row scrolls into view, the lookup is a single dictionary read.
        $brush = $null
        if (!$brushMap.TryGetValue($item, [ref]$brush)) {
            $brush = & $evaluate $item
            $brushMap[$item] = $brush
        }

        if ($null -ne $brush) { $row.Background = $brush }
        else { $row.ClearValue([System.Windows.Controls.Control]::BackgroundProperty) }
    }.GetNewClosure())

    # Source sync. Sort/filter don't fire CollectionChanged on the source, so this only runs for real Add/Remove/Replace/Reset.
    $buildHandler = {
        # Rebind to locals: nested .GetNewClosure() captures only this invocation's locals, not what $buildHandler itself captured - $brushMap was $null inside the running handler, so Remove bookkeeping leaked and Reset never cleared the cache.
        $mapRef = $brushMap
        # Cast to the delegate type so add_/remove_CollectionChanged see the same instance - the raw scriptblock rides PS's conversion cache and the detach can miss under GC.
        $handler = [System.Collections.Specialized.NotifyCollectionChangedEventHandler]{
            param($sender, $eventArgs)
            # Drop keys, never compute. LoadingRow evaluates at realization, so an offscreen row never runs the user scriptblock and a feed of per row Adds stays cheap on the UI thread.
            switch ($eventArgs.Action) {
                ([System.Collections.Specialized.NotifyCollectionChangedAction]::Add) {
                    foreach ($newItem in $eventArgs.NewItems) { [void]$mapRef.Remove($newItem) }
                }
                ([System.Collections.Specialized.NotifyCollectionChangedAction]::Remove) {
                    foreach ($oldItem in $eventArgs.OldItems) { [void]$mapRef.Remove($oldItem) }
                }
                ([System.Collections.Specialized.NotifyCollectionChangedAction]::Replace) {
                    foreach ($oldItem in $eventArgs.OldItems) { [void]$mapRef.Remove($oldItem) }
                    foreach ($newItem in $eventArgs.NewItems) { [void]$mapRef.Remove($newItem) }
                }
                ([System.Collections.Specialized.NotifyCollectionChangedAction]::Reset) {
                    # Clear only. LoadingRow refills on miss - prefilling here runs the user scriptblock once per source row on the UI thread, and a ReplaceAll with 50k rows turns that into a freeze.
                    $mapRef.Clear()
                }
            }
        }.GetNewClosure()
        return $handler
    }.GetNewClosure()

    # Unary comma, or the return unrolls the collection into its elements and $attach ends up testing an Object[] for INotifyCollectionChanged - always false, so it never subscribes. A dead subscription still looks healthy: rows keep coloring on the way in through the LoadingRow miss.
    $resolveSource = {
        $view = $DataGrid.ItemsSource -as [System.ComponentModel.ICollectionView]
        if ($view) { return ,$view.SourceCollection }
        return ,$DataGrid.ItemsSource
    }.GetNewClosure()

    $attach = {
        $newSource = & $resolveSource
        # ReferenceEquals, not -eq: PS enumerates a collection LHS, so -eq never compares references (a source tested against itself came back falsy, and a source holding two null rows compared truthy against $null).
        if ([object]::ReferenceEquals($newSource, $subState.Source)) { return }

        # Detach the old subscription before swapping. Leaving it hooked leaks the grid via the source's event multicast, and the new source goes silent on Add/Remove.
        # An EMPTY collection is falsy, and the column surgery on initially empty -ItemsSource grids swaps ItemsSource right when the source is empty. A skipped detach here left the source subscribed twice, so every added row ran the user scriptblock twice.
        if ($null -ne $subState.Source -and $subState.Handler) {
            try { $subState.Source.remove_CollectionChanged($subState.Handler) }
            catch { Write-Debug "RowBackground old-source detach failed: $_" }
        }

        $brushMap.Clear()
        $subState.Source  = $newSource
        $subState.Handler = $null

        if ($newSource -is [System.Collections.Specialized.INotifyCollectionChanged]) {
            $handler = & $buildHandler
            try {
                $newSource.add_CollectionChanged($handler)
                $subState.Handler = $handler
            }
            catch { Write-Debug "RowBackground source subscribe failed: $_" }
        }
    }.GetNewClosure()

    & $attach

    # Watch ItemsSource swaps. DPD.AddValueChanged parks a strong ref to the grid in WPF's internal table, which has never once released anything voluntarily - cleanup MUST land on Window.Closed. Not Unloaded: tab switches fire Unloaded too, and the user is coming back to that grid.
    $itemsSourcePropDesc = [System.ComponentModel.DependencyPropertyDescriptor]::FromProperty(
        [System.Windows.Controls.ItemsControl]::ItemsSourceProperty,
        [System.Windows.Controls.DataGrid])

    $itemsSourceChangedHandler = [System.EventHandler]{
        param($sender, $eventArgs)
        & $attach
    }.GetNewClosure()

    $onWindowClosed = {
        try { $itemsSourcePropDesc.RemoveValueChanged($DataGrid, $itemsSourceChangedHandler) }
        catch { Write-Debug "RowBackground DPD detach failed: $_" }
        if ($null -ne $subState.Source -and $subState.Handler) {
            try { $subState.Source.remove_CollectionChanged($subState.Handler) }
            catch { Write-Debug "RowBackground source detach failed: $_" }
            $subState.Source  = $null
            $subState.Handler = $null
        }
    }.GetNewClosure()

    $initHooked = @{ Value = $false }
    $hookWindow = {
        if ($initHooked.Value) { return }
        $window = [System.Windows.Window]::GetWindow($DataGrid)
        if (!$window) { return }
        $initHooked.Value = $true
        $itemsSourcePropDesc.AddValueChanged($DataGrid, $itemsSourceChangedHandler)
        $window.Add_Closed($onWindowClosed)
    }.GetNewClosure()

    # Initialized fires before Loaded. A grid parented at construction hooks immediately. Loaded covers grids that get a parent later (deferred Content assignment etc).
    $DataGrid.Add_Initialized({ & $hookWindow }.GetNewClosure())
    $DataGrid.Add_Loaded({ & $hookWindow }.GetNewClosure())
}