public/list/Set-UiDataGridItems.ps1

function Set-UiDataGridItems {
    <#
    .SYNOPSIS
        Replaces every row in a New-UiDataGrid.
    .DESCRIPTION
        Swaps the entire row set on the grid identified by -Variable. Works on both -Items
        grids (PsUi owns the collection) and -ItemsSource grids (PsUi shares your collection).
        One repaint for the whole swap, not one per row. PowerShell added properties stay
        visible. Filter and sort reapply.
    .PARAMETER Variable
        The -Variable name passed to the originating New-UiDataGrid.
    .PARAMETER Items
        The new rows. Pass @() (or $null) to wipe the grid - both mean "no rows", the same as
        anywhere else in PowerShell, so a filter that matched nothing clears the grid instead of
        throwing. Null elements inside the array are dropped: a null row is unrenderable.
    .PARAMETER PassThru
        Return the rows as they ended up in the grid. On -Items grids that's the copies PsUi
        displays. On -ItemsSource grids it's your array with null rows removed and any hashtable
        rows swapped for their converted PSCustomObjects. Changing a property on a returned row
        won't repaint the cell on its own - PSCustomObject rows raise no change notifications, so
        rerun Set-UiDataGridItems to show the change.
    .EXAMPLE
        New-UiButton -Text 'Refresh' -Action {
            Set-UiDataGridItems -Variable procs -Items (Get-Process)
        }
    .EXAMPLE
        $liveRows = Set-UiDataGridItems -Variable procs -Items $batch -PassThru
        $liveRows[0].Status = 'Reviewed' # updates the object. Reset the grid to repaint
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Variable,

        # [AllowEmptyCollection()] is the magic that lets `-Items @()` through. Mandatory parameter binding rejects empty arrays by default, which makes the "wipe the grid" call site throw.
        # [AllowNull()] covers the other half: a Mandatory [object[]] also throws when the array holds a null ELEMENT. Owned grids drop it in the snapshot. The ItemsSource branch drops it in the prepare loop below (a null row rides into the shared collection otherwise - the grid filter hides it, but clear the filter and WPF tries to realize a null key and dies).
        [Parameter(Mandatory, Position = 1)]
        [AllowEmptyCollection()]
        [AllowNull()]
        [object[]]$Items,

        [switch]$PassThru
    )

    $session = Get-UiSession
    if (!$session) { Write-Error 'Set-UiDataGridItems: no active PsUi session. Call it from a button action or inside a New-UiWindow.'; return }
    $collection = $session.GetListCollection($Variable)
    if ($null -eq $collection) {
        $keys = $session.GetAllListKeys() -join ', '
        Write-Error "DataGrid '$Variable' not found. Known list/grid variables: $keys"
        return
    }

    if (Test-UiDataGridOwned -Collection $collection) {
        # Snapshot keeps ETS members rendering and rebuilds the search index. Dictionaries go in RAW - the snapshot's own IDictionary branch converts them AND keeps _BaseObject on the caller's dict. Preconverting here handed it a throwaway PSCustomObject and edit writeback landed on that instead of the caller's object.
        $final = @(ConvertTo-UiDataGridSnapshot -Items $Items -BuildSearchIndex)
    }
    else {
        # ItemsSource wrap: feed raw items so the mirror holds the caller's objects - except dictionaries, which WPF binding paths can't read. IDictionary, not [hashtable]: [ordered]@{} fails the narrower check and rode this path in raw, rendering blank rows. Same rule as Add-UiDataGridItem's ItemsSource branch.
        $prepared = [System.Collections.Generic.List[object]]::new($Items.Count)
        foreach ($entry in $Items) {
            # Match the owned path - a null row is unrenderable, keep it out of the caller's mirror.
            if ($null -eq $entry) { continue }
            if ($entry -is [System.Collections.IDictionary]) { [void]$prepared.Add([PSCustomObject]$entry) }
            else                                             { [void]$prepared.Add($entry) }
        }
        $final = $prepared.ToArray()
    }

    # ReplaceAll = single Reset. Fallback path handles a stale DLL load (e.g., the .ps1 files were updated past a Build-PsUi.ps1 without restarting PS).
    $replaceAll = $collection.GetType().GetMethod('ReplaceAll')
    if ($replaceAll) {
        $typed = [System.Collections.Generic.List[object]]::new($final.Count)
        foreach ($entry in $final) { [void]$typed.Add($entry) }
        $collection.ReplaceAll($typed)
    }
    else {
        $collection.Clear()
        foreach ($entry in $final) { [void]$collection.Add($entry) }
    }

    if ($PassThru) { return $final }
}