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.
    # Typed on the variable, since an if expression hands back object[]
    [string[]]$defaultPropNamesArr = if ($defaultPropertyNames -and $defaultPropertyNames.Count -gt 0) { $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)
    $commandLines = $null
    $copyRefused  = @{}

    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
        }

        $display = $null
        if ($item -is [System.Management.Automation.PSCustomObject]) {
            # Copy() throws on a deserialized row from Import-Clixml or Invoke-Command
            $rowType = [string]$item.PSObject.TypeNames[0]
            if (!$copyRefused.ContainsKey($rowType)) {
                try { $display = $item.PSObject.Copy() }
                catch {
                    # One throw per type, at 300us a row on 5.1
                    $copyRefused[$rowType] = $true
                    Write-Debug "Copy() refused a $rowType row: $_"
                }
            }
        }

        if ($null -ne $display) {

            # 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 }
            $isProcess    = $item -is [System.Diagnostics.Process]

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

                $name = $prop.Name
                if ($name.StartsWith('_')) { continue }
                if ($prop -is [System.Management.Automation.PSMemberSet]) { continue }

                # Modules walks every loaded DLL, 10ms a process on either edition and 3.9s for 390. $row._BaseObject.Modules still has it in there
                if ($isProcess -and $name -eq 'Modules') { continue }

                $val = $null
                if ($isProcess -and $name -eq 'CommandLine') {
                    # On 7 this ScriptProperty runs a CIM query per process, 54s for 390, where one query for all of them takes 150-240ms.
                    # Leaving it off the row doesn't work, because the row carries the Process type name and the type data puts the ScriptProperty straight back
                    if ($null -eq $commandLines) {
                        $commandLines = @{}
                        try {
                            $cimProcesses = Get-CimInstance -ClassName Win32_Process -Property ProcessId, CommandLine -ErrorAction Stop

                            # ProcessId is UInt32 against Process.Id's Int32, so cast or miss
                            foreach ($cimProcess in $cimProcesses) {  $commandLines[[int]$cimProcess.ProcessId] = $cimProcess.CommandLine }
                        }
                        catch { Write-Debug "Bulk CommandLine query failed: $_" }
                    }

                    # If the Process never started its Id reads null, and a null key throws
                    if ($null -ne $item.Id) { $val = $commandLines[$item.Id] }
                }
                else {
                    try { $val = $prop.Value }
                    catch {
                        # Throws on protected ones, like MainModule on an elevated process
                        $val = $null
                    }
                }

                $snap[$name] = $val

                # Parent is a Process built from an Id, and its ToString rereads the process table, 1.6s over a full Get-Process on 7.
                if ($searchBuffer -and $null -ne $val -and !($isProcess -and $name -eq 'Parent')) {
                    # [string] read a hashtable as its type name
                    [void]$searchBuffer.Append([PsUi.ValueKind]::IndexText($val, 25, 512))
                    [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
}