From 441537a7cb12e1849e952cf835866a9a2adf81c3 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 12:50:16 +0530 Subject: [PATCH] refactor(replay-ui): extract snapshot flatten/getAtPath into lib module --- replay-ui/src/lib/snapshot-diff.ts | 101 +++++++++++++++++++++++++ replay-ui/src/panels/SnapshotTable.tsx | 98 +----------------------- 2 files changed, 102 insertions(+), 97 deletions(-) create mode 100644 replay-ui/src/lib/snapshot-diff.ts diff --git a/replay-ui/src/lib/snapshot-diff.ts b/replay-ui/src/lib/snapshot-diff.ts new file mode 100644 index 0000000..cb75914 --- /dev/null +++ b/replay-ui/src/lib/snapshot-diff.ts @@ -0,0 +1,101 @@ +export interface Row { + path: string; + value: unknown; +} + +export const INLINE_ARRAY_LIMIT = 2; + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +export function flatten(input: Record): Row[] { + const rows: Row[] = []; + const walk = (value: unknown, path: string) => { + if (isPlainObject(value)) { + const keys = Object.keys(value).sort(); + if (keys.length === 0) { + rows.push({ path, value: {} }); + return; + } + for (const key of keys) { + const nextPath = path === "" ? key : `${path}.${key}`; + walk(value[key], nextPath); + } + return; + } + if (Array.isArray(value)) { + if (value.length <= INLINE_ARRAY_LIMIT) { + rows.push({ path, value }); + return; + } + for (let i = 0; i < value.length; i++) { + walk(value[i], `${path}[${i}]`); + } + return; + } + rows.push({ path, value }); + }; + for (const key of Object.keys(input).sort()) { + walk(input[key], key); + } + return rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +export function getAtPath( + source: Record | undefined, + path: string, +): unknown { + if (!source) { + return undefined; + } + if (Object.prototype.hasOwnProperty.call(source, path)) { + return source[path]; + } + const segments = path + .split(/\.|\[(\d+)\]/) + .filter((segment) => segment !== undefined && segment !== ""); + let current: unknown = source; + for (const segment of segments) { + if (current === null || current === undefined) { + return undefined; + } + if (Array.isArray(current)) { + const index = Number(segment); + if (Number.isNaN(index)) { + return undefined; + } + current = current[index]; + continue; + } + if (typeof current === "object") { + current = (current as Record)[segment]; + continue; + } + return undefined; + } + return current; +} + +export function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (isPlainObject(value)) { + const out: Record = {}; + for (const key of Object.keys(value).sort()) { + out[key] = canonicalize(value[key]); + } + return out; + } + return value; +} + +export function stableStringify(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} diff --git a/replay-ui/src/panels/SnapshotTable.tsx b/replay-ui/src/panels/SnapshotTable.tsx index 7848316..54e96eb 100644 --- a/replay-ui/src/panels/SnapshotTable.tsx +++ b/replay-ui/src/panels/SnapshotTable.tsx @@ -1,3 +1,4 @@ +import { flatten, getAtPath, stableStringify } from "../lib/snapshot-diff"; import "./SnapshotTable.css"; export interface SnapshotTableProps { @@ -5,103 +6,6 @@ export interface SnapshotTableProps { previousSnapshots?: Record; } -interface Row { - path: string; - value: unknown; -} - -const INLINE_ARRAY_LIMIT = 2; - -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.getPrototypeOf(value) === Object.prototype - ); -} - -function flatten(input: Record): Row[] { - const rows: Row[] = []; - const walk = (value: unknown, path: string) => { - if (isPlainObject(value)) { - const keys = Object.keys(value).sort(); - if (keys.length === 0) { - rows.push({ path, value: {} }); - return; - } - for (const key of keys) { - const nextPath = path === "" ? key : `${path}.${key}`; - walk(value[key], nextPath); - } - return; - } - if (Array.isArray(value)) { - if (value.length <= INLINE_ARRAY_LIMIT) { - rows.push({ path, value }); - return; - } - for (let i = 0; i < value.length; i++) { - walk(value[i], `${path}[${i}]`); - } - return; - } - rows.push({ path, value }); - }; - for (const key of Object.keys(input).sort()) { - walk(input[key], key); - } - return rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); -} - -function getAtPath(source: Record | undefined, path: string): unknown { - if (!source) { - return undefined; - } - if (Object.prototype.hasOwnProperty.call(source, path)) { - return source[path]; - } - const segments = path.split(/\.|\[(\d+)\]/).filter((segment) => segment !== undefined && segment !== ""); - let current: unknown = source; - for (const segment of segments) { - if (current === null || current === undefined) { - return undefined; - } - if (Array.isArray(current)) { - const index = Number(segment); - if (Number.isNaN(index)) { - return undefined; - } - current = current[index]; - continue; - } - if (typeof current === "object") { - current = (current as Record)[segment]; - continue; - } - return undefined; - } - return current; -} - -function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalize); - } - if (isPlainObject(value)) { - const out: Record = {}; - for (const key of Object.keys(value).sort()) { - out[key] = canonicalize(value[key]); - } - return out; - } - return value; -} - -function stableStringify(value: unknown): string { - return JSON.stringify(canonicalize(value)); -} - function formatValue(value: unknown): string { if (value === null) { return "null";