private/Controls/ConvertTo-UiDataGridSnapshot.ps1

function ConvertTo-UiDataGridSnapshot {
    <#
    .SYNOPSIS
        Flattens .NET objects into PSCustomObjects so PS added properties bind. Drops nulls.
    #>

    [CmdletBinding()]
    param(
        # AllowNull: a Mandatory [object[]] rejects the whole array if ANY element is null (not just a null array), so an owned grid built from rows carrying a null would throw during parameter binding before the null skip below ever runs.
        # The body drops nulls. Let them bind.
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [AllowNull()]
        [object[]]$Items,

        [switch]$BuildSearchIndex
    )

    # $null check, not truthiness: a lone falsy scalar (@(0)/@('')/@($false)) unwraps to a falsy value under !$Items, so the only row got dropped and the grid rendered blank.
    if ($null -eq $Items -or $Items.Count -eq 0) {
        return [System.Collections.Generic.List[object]]::new()
    }

    # First item that actually needs snapshotting hands over the DefaultDisplayPropertySet (uniform per collection, no point running a good ol probe on each row).
    # TypeNames are per item below so a mixed collection (Process + ServiceController in the same array) keeps row accurate types.
    $firstSnapshotItem = $null
    foreach ($probe in $Items) {
        if ($null -eq $probe) { continue }
        if ($probe -is [string] -or $probe -is [System.ValueType]) { continue }
        if ($probe -is [System.Collections.IDictionary]) { continue }
        if ($probe -is [System.Management.Automation.PSCustomObject]) { continue }

        $firstSnapshotItem = $probe
        break
    }

    $defaultPropertyNames = $null

    if ($firstSnapshotItem) {
        try {
            $stdMembers = $firstSnapshotItem.PSStandardMembers
            if ($stdMembers -and $stdMembers.DefaultDisplayPropertySet) {
                $defaultPropertyNames = [System.Collections.Generic.List[string]]::new()
                foreach ($propName in $stdMembers.DefaultDisplayPropertySet.ReferencedPropertyNames) {
                    $defaultPropertyNames.Add([string]$propName)
                }
            }
        }
        catch { Write-Debug "DefaultDisplayPropertySet probe failed: $_" }
    }

    # Cast once so PSMemberSet construction below doesn't recast per row.
    $defaultPropNamesArr = if ($defaultPropertyNames -and $defaultPropertyNames.Count -gt 0) { [string[]]$defaultPropertyNames } else { $null }

    # PSPropertySet content is identical for every row in a homogeneous collection.
    # Build once outside the loop. Only the per row PSMemberSet has to stay inside - those can't be shared across objects.
    $sharedPropSet = $null
    $sharedMembers = $null
    if ($defaultPropNamesArr) {
        try {
            $sharedPropSet = [System.Management.Automation.PSPropertySet]::new('DefaultDisplayPropertySet', $defaultPropNamesArr)
            # PSMemberSet's constructor takes PSMemberInfo[]. A plain @($propSet) gives object[] and PowerShell picks the wrong constructor.
            [System.Management.Automation.PSMemberInfo[]]$sharedMembers = @($sharedPropSet)
        }
        catch {
            Write-Debug "Couldn't build shared PSPropertySet: $_"
            $sharedMembers = $null
        }
    }

    $result = [System.Collections.Generic.List[object]]::new($Items.Count)

    foreach ($item in $Items) {
        if ($null -eq $item) { continue }

        if ($item -is [string] -or $item -is [System.ValueType]) { [void]$result.Add($item); continue }
        if ($item -is [System.Collections.IDictionary]) {
            # WPF binding paths can't see IDictionary keys. A raw hashtable renders Count/Keys/Values columns and none of the user's data. Convert. _BaseObject keeps the user's original.
            try {
                $converted = [PSCustomObject]$item
                $converted.PSObject.Properties.Add(
                    [System.Management.Automation.PSNoteProperty]::new('_BaseObject', $item))
                if ($BuildSearchIndex) { Add-UiDataGridSearchText -PsObject $converted }
                [void]$result.Add($converted)
            }
            catch {
                Write-Debug "Hashtable conversion failed, keeping original: $_"
                [void]$result.Add($item)
            }
            continue
        }
        if ($item -is [System.Management.Automation.PSCustomObject]) {

            # Copy() gives an independent property bag over the same values - the NoteProperties below land on the DISPLAY row, never the user's object.
            # Mutating the original leaked _BaseObject/_SearchText into the user's own Export-Csv and ConvertTo-Json (the self referential _BaseObject recursed to the json depth cap), and -PassThru handed back rows wearing grid internals.
            $display = $item.PSObject.Copy()

            # Keep the _BaseObject contract consistent across input types. Downstream consumers (context menus, action handlers) reach for $row._BaseObject without caring how the row got into the grid.
            # A repassed display row already carries one pointing at the true original - keep that chain intact.
            if (!$display.PSObject.Properties['_BaseObject']) {
                try {
                    $display.PSObject.Properties.Add(
                        [System.Management.Automation.PSNoteProperty]::new('_BaseObject', $item))
                }
                catch { Write-Debug "Couldn't attach _BaseObject to PSCustomObject: $_" }
            }

            if ($BuildSearchIndex) { Add-UiDataGridSearchText -PsObject $display -Force }
            [void]$result.Add($display)
            continue
        }

        # Per item guard. Some objects throw beyond a single property access (services in restricted contexts).
        # Fall back to the original row so one bad object doesn't take the grid down.
        try {
            $snap         = [ordered]@{}
            # 256 char initial capacity covers a typical property heavy row (~10 props * ~25 chars) without reallocs.
            $searchBuffer = if ($BuildSearchIndex) { [System.Text.StringBuilder]::new(256) } else { $null }

            foreach ($prop in $item.PSObject.Properties) {

                $name = $prop.Name
                if ($name.StartsWith('_')) { continue }
                if ($prop -is [System.Management.Automation.PSMemberSet]) { continue }
                $val = $null
                try { $val = $prop.Value }
                catch {
                    # ScriptProperty throws on protected resources (Process.MainModule on elevated procs). Null is what the grid would render anyway.
                    $val = $null
                }

                $snap[$name] = $val
                if ($searchBuffer -and $null -ne $val) {
                    [void]$searchBuffer.Append([string]$val)
                    [void]$searchBuffer.Append(' ')
                }
            }

            # _BaseObject keeps the original .NET object one hop away - e.g. Stop-Process -InputObject $row._BaseObject.
            $snap['_BaseObject'] = $item
            if ($searchBuffer) { $snap['_SearchText'] = $searchBuffer.ToString() }
            $snapObj = [PSCustomObject]$snap

            # Reattach PSStandardMembers per snapshot - DefaultDisplayPropertySet won't fire without it, and PSObject members can't be shared between objects.
            if ($sharedMembers) {
                try {
                    $memberSet = [System.Management.Automation.PSMemberSet]::new('PSStandardMembers', $sharedMembers)
                    $snapObj.PSObject.Members.Add($memberSet)
                }
                catch { Write-Debug "Couldn't attach PSStandardMembers to snapshot: $_" }
            }

            # Carry only this row's TypeName, not a collection wide one... heterogeneous arrays (Process + Service in the same Out-Datagrid) otherwise stamp every row with the first item's type and the regex fallbacks misfire.
            try {
                $rowTypeName = if ($item.PSObject.TypeNames.Count -gt 0) { [string]$item.PSObject.TypeNames[0] } else { $null }
                if ($rowTypeName -and $snapObj.PSObject.TypeNames -notcontains $rowTypeName) { $snapObj.PSObject.TypeNames.Insert(0, $rowTypeName) }
            }
            catch { Write-Debug "Couldn't insert row type name: $_" }

            [void]$result.Add($snapObj)
        }
        catch {
            Write-Debug "Snapshot failed for item, falling back to original: $_"
            [void]$result.Add($item)
        }
    }

    return $result
}