From 69d182086431a6b8020775877eff8aabfb1a0642 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:29:56 +0000 Subject: [PATCH 01/28] enhance(website): Redesign the playground Store inspector Replace the react-json-tree dump with a store view built around how Data Client normalizes data. Tables by default, a Tree view as a saved option. Ids are links in both directions, long lists collapse, and every stored value stays reachable from a row's expanded detail. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SaF7H7PxLeSAoqV55xaXL3 --- website/package.json | 1 - .../components/Playground/preview/Preview.tsx | 21 +- .../Playground/preview/StoreInspector.tsx | 20 +- .../components/Playground/preview/Tree.tsx | 116 ------ .../preview/__tests__/storeModel.test.ts | 124 ++++++ .../Playground/preview/store/Details.tsx | 136 ++++++ .../Playground/preview/store/Sections.tsx | 246 +++++++++++ .../Playground/preview/store/StorePanel.tsx | 109 +++++ .../Playground/preview/store/StoreUI.tsx | 102 +++++ .../Playground/preview/store/TableView.tsx | 191 +++++++++ .../Playground/preview/store/TreeView.tsx | 93 +++++ .../Playground/preview/store/Value.tsx | 223 ++++++++++ .../Playground/preview/store/model.ts | 158 +++++++ .../Playground/preview/store/refs.ts | 135 ++++++ .../preview/store/schemaRegistry.ts | 52 +++ .../Playground/preview/store/store.module.css | 387 ++++++++++++++++++ .../components/Playground/styles.module.css | 23 ++ yarn.lock | 3 +- 18 files changed, 2002 insertions(+), 138 deletions(-) delete mode 100644 website/src/components/Playground/preview/Tree.tsx create mode 100644 website/src/components/Playground/preview/__tests__/storeModel.test.ts create mode 100644 website/src/components/Playground/preview/store/Details.tsx create mode 100644 website/src/components/Playground/preview/store/Sections.tsx create mode 100644 website/src/components/Playground/preview/store/StorePanel.tsx create mode 100644 website/src/components/Playground/preview/store/StoreUI.tsx create mode 100644 website/src/components/Playground/preview/store/TableView.tsx create mode 100644 website/src/components/Playground/preview/store/TreeView.tsx create mode 100644 website/src/components/Playground/preview/store/Value.tsx create mode 100644 website/src/components/Playground/preview/store/model.ts create mode 100644 website/src/components/Playground/preview/store/refs.ts create mode 100644 website/src/components/Playground/preview/store/schemaRegistry.ts create mode 100644 website/src/components/Playground/preview/store/store.module.css diff --git a/website/package.json b/website/package.json index e1d033d2455e..a22d615d4297 100644 --- a/website/package.json +++ b/website/package.json @@ -82,7 +82,6 @@ "parse-numeric-range": "^1.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-json-tree": "0.20.0", "react-live": "^4.0.0", "temporal-polyfill": "^0.3.0", "typescript": "npm:@typescript/typescript6@6.0.2", diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index e81d49e320bb..5c72b39cae72 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -3,6 +3,7 @@ import { PollingSubscription, SubscriptionManager, NetworkManager, + type Manager, } from '@data-client/react'; import { MockResolver } from '@data-client/test/browser'; import { useScrollPositionBlocker } from '@docusaurus/theme-common/internal'; @@ -16,6 +17,7 @@ import React, { } from 'react'; import Boundary from '../Boundary'; +import SchemaRegistry from './store/schemaRegistry'; import StoreInspector from './StoreInspector'; import { useTabStorage } from '../../../utils/tabStorage'; import styles from '../styles.module.css'; @@ -50,10 +52,15 @@ function Preview({ ], ); - const managers = useMemo( - () => [new NetworkManager(), new SubscriptionManager(PollingSubscription)], - [], - ); + const [registry, managers] = useMemo(() => { + const registry = new SchemaRegistry(); + const managers: Manager[] = [ + registry, + new NetworkManager(), + new SubscriptionManager(PollingSubscription), + ]; + return [registry, managers] as const; + }, []); const hiddenResult = row && selectedValue === 'y'; return ( @@ -72,7 +79,11 @@ function Preview({ - + ); diff --git a/website/src/components/Playground/preview/StoreInspector.tsx b/website/src/components/Playground/preview/StoreInspector.tsx index 81d861af8e89..24166db5367c 100644 --- a/website/src/components/Playground/preview/StoreInspector.tsx +++ b/website/src/components/Playground/preview/StoreInspector.tsx @@ -1,23 +1,25 @@ -import { StateContext } from '@data-client/react'; import clsx from 'clsx'; -import React, { useContext, memo, useMemo } from 'react'; +import React, { memo } from 'react'; import styles from '../styles.module.css'; -import Tree from './Tree'; +import type SchemaRegistry from './store/schemaRegistry'; +import StorePanel from './store/StorePanel'; function StoreInspector({ toggle, selectedValue, + registry, }: { selectedValue: 'y' | 'n'; toggle: React.MouseEventHandler; + registry: SchemaRegistry; }) { const isSelected = selectedValue === 'y'; return ( <> {isSelected ? - + : null} ); @@ -47,13 +49,3 @@ export function StoreToggle({ ); } - -function StoreTree() { - const state = useContext(StateContext); - const simplifiedState = useMemo(() => { - const { optimistic, ...ret } = state; - return ret; - }, [state]); - return ; -} -const StoreTreeM = memo(StoreTree); diff --git a/website/src/components/Playground/preview/Tree.tsx b/website/src/components/Playground/preview/Tree.tsx deleted file mode 100644 index 2b1f00f86634..000000000000 --- a/website/src/components/Playground/preview/Tree.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useColorMode } from '@docusaurus/theme-common'; -import React, { useMemo, type CSSProperties } from 'react'; -import { JSONTree, type KeyPath, type ValueRenderer } from 'react-json-tree'; - -const valueColorMap = { - String: 'rgb(195, 232, 141)', - Date: 'rgb(247, 140, 108)', - Number: 'rgb(247, 140, 108)', - Boolean: 'rgb(247, 140, 108)', - Null: 'rgb(255, 88, 116)', - Undefined: 'rgb(255, 88, 116)', - Function: 'rgb(247, 140, 108)', - Symbol: 'rgb(247, 140, 108)', -}; - -export default function Output({ value }: { value: unknown }) { - const isDarkTheme = useColorMode().colorMode === 'dark'; - const theme = useMemo( - () => ({ - tree: { - overflow: 'auto', - flex: '4 1 40%', - margin: 0, - padding: '0 0.5rem 0 0.8rem', - backgroundColor: - isDarkTheme ? 'var(--ifm-pre-background)' : 'rgb(41, 45, 62)', - font: 'var(--ifm-code-font-size) / var(--ifm-pre-line-height) var(--ifm-font-family-monospace) !important', - color: 'rgb(227, 227, 227)', - }, - arrowContainer: ({ style }: { style: CSSProperties }) => ({ - style: { - ...style, - fontFamily: 'arial', - padding: '7px 7px 7px 0', - margin: '-7px calc(0.5em - 7px) -7px 0', - }, - }), - arrowSign: { - color: 'rgb(130, 170, 255)', - }, - label: { - color: 'rgb(130, 170, 255)', - }, - itemRange: { - color: 'rgb(105, 112, 152)', - }, - valueText: ( - { style }: { style: CSSProperties }, - nodeType: keyof typeof valueColorMap, - ) => ({ - style: { - ...style, - color: valueColorMap[nodeType], - }, - }), - base0B: 'rgb(191, 199, 213)', - }), - [isDarkTheme], - ); - const shouldExpandNodeInitially = useMemo(shouldExpandNode, []); - return ( - - ); -} - -const shouldExpandNode = () => { - let entityCount = 0; - let schemaCount = 0; - - return (keyName: KeyPath, _data: unknown, level: number) => { - if (level === 0) return true; - if (level === 1 && ['entities', 'results'].includes(String(keyName[0]))) - return true; - if (level === 2 && keyName[1] === 'entities') return schemaCount++ < 2; - if (level === 2 && keyName[1] === 'results') return true; - if (level === 3 && keyName[2] === 'entities') { - return entityCount++ < 4; - } - if (level === 3 && keyName[2] === 'results') return true; - return false; - }; -}; - -const timeFormatter = - typeof Intl !== 'undefined' ? - Intl.DateTimeFormat('en-US', { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - fractionalSecondDigits: 3, - }) - : undefined; - -const valueRenderer: ValueRenderer = ( - valueAsString: unknown, - value: unknown, - ...keyPath: KeyPath -) => { - const key = keyPath[0]; - if ( - timeFormatter && - typeof value === 'number' && - typeof key === 'string' && - isFinite(value) && - (key === 'date' || key.endsWith('At')) - ) { - return timeFormatter.format(value); - } - return valueAsString as string; -}; diff --git a/website/src/components/Playground/preview/__tests__/storeModel.test.ts b/website/src/components/Playground/preview/__tests__/storeModel.test.ts new file mode 100644 index 000000000000..532cce30e93f --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/storeModel.test.ts @@ -0,0 +1,124 @@ +/// +import { Collection, Entity, schema } from '@data-client/endpoint'; +import type { State } from '@data-client/react'; + +import { buildModel, changedIds, entityId, splitKey } from '../store/model'; +import { resolve } from '../store/refs'; +import SchemaRegistry from '../store/schemaRegistry'; + +class User extends Entity { + id = ''; + name = ''; +} +class Comment extends Entity { + id = ''; + commenter = User.fromJS(); + static schema = { commenter: User }; +} +class Post extends Entity { + id = ''; + author = User.fromJS(); + comments: Comment[] = []; + static schema = { + author: User, + comments: new Collection([Comment], { + nestKey: (parent: any) => ({ postId: parent.id }), + }), + }; +} + +const state = { + meta: {}, + entitiesMeta: {}, + indexes: {}, + optimistic: [], + lastReset: 0, + endpoints: { 'GET https://example.com/posts': ['1'] }, + entities: { + User: { 123: { id: '123', name: 'Paul' } }, + Comment: { 249: { id: '249', commenter: '123' } }, + Post: { 1: { id: '1', author: '123', comments: '{"postId":"1"}' } }, + '[Comment]': { '{"postId":"1"}': ['249'] }, + }, +} as unknown as State; + +function registryFor(key: string, s: any) { + const registry = new SchemaRegistry(); + registry.endpoints.set(key, { endpoint: { schema: s }, args: [] }); + registry.learn(s); + return registry; +} + +describe('store model', () => { + it('learns every entity table reachable from an endpoint schema', () => { + const registry = registryFor('k', [Post]); + expect([...registry.entities.keys()].sort()).toEqual( + ['Comment', 'Post', 'User', '[Comment]'].sort(), + ); + }); + + it('resolves references through Collections and Unions', () => { + const union = new schema.Union( + { users: User, posts: Post }, + (input: any) => input.type, + ); + expect(resolve({ id: '1', schema: 'posts' }, union)).toEqual({ + t: 'ref', + key: 'Post', + pk: '1', + }); + expect(resolve([{ id: '123', schema: 'users' }], [union])).toEqual({ + t: 'arr', + items: [{ t: 'ref', key: 'User', pk: '123' }], + }); + }); + + it('indexes who references each entity', () => { + const model = buildModel( + state, + registryFor('GET https://example.com/posts', [Post]), + ); + const labels = (key: string, pk: string) => + model.referrers.get(entityId(key, pk))?.map(r => r.label); + expect(labels('Post', '1')).toEqual(['GET /posts']); + expect(labels('User', '123')).toEqual(['Comment 249', 'Post 1']); + expect(labels('Comment', '249')).toEqual(['[Comment] {"postId":"1"}']); + expect(model.tables.map(t => [t.key, t.kind])).toEqual([ + ['User', 'entity'], + ['Comment', 'entity'], + ['Post', 'entity'], + ['[Comment]', 'collection'], + ]); + }); + + it('shows unknown schemas as plain values', () => { + const model = buildModel(state, new SchemaRegistry()); + expect(model.endpoints[0].value).toEqual({ + t: 'arr', + items: [{ t: 'val', v: '1' }], + }); + expect(model.referrers.size).toBe(0); + }); + + it('finds changed rows', () => { + const next = { + ...state, + entities: { + ...state.entities, + User: { 123: { id: '123', name: 'Paul Jones' } }, + }, + }; + expect([...changedIds(state, next)]).toEqual([entityId('User', '123')]); + }); + + it('splits endpoint keys', () => { + expect(splitKey('GET https://example.com/posts?page=2')).toEqual({ + method: 'GET', + path: '/posts?page=2', + }); + expect(splitKey('custom key')).toEqual({ + method: '', + path: 'custom key', + }); + }); +}); diff --git a/website/src/components/Playground/preview/store/Details.tsx b/website/src/components/Playground/preview/store/Details.tsx new file mode 100644 index 000000000000..6085fcbc40f6 --- /dev/null +++ b/website/src/components/Playground/preview/store/Details.tsx @@ -0,0 +1,136 @@ +import { StateContext, useController } from '@data-client/react'; +import clsx from 'clsx'; +import React, { useContext, useEffect, useState } from 'react'; + +import { + isEndpointId, + type EndpointRow, + type EntityRow, + type Referrer, +} from './model'; +import { plain } from './refs'; +import type { EndpointRecord } from './schemaRegistry'; +import { Segmented } from './Sections'; +import styles from './store.module.css'; +import { Block, Field, formatTime, RefChip } from './Value'; + +type Meta = EndpointRow['meta']; + +/** fresh (with countdown), stale, error or invalidated */ +export function Status({ meta }: { meta: Meta }) { + const now = useNow(meta?.expiresAt); + if (!meta) return null; + if (meta.error) + return error; + if (meta.invalidated) + return invalid; + const left = meta.expiresAt - now; + return left > 0 ? + + fresh {seconds(left)} + + : stale; +} + +/** Re-renders each second until `until` passes */ +function useNow(until: number | undefined) { + const [now, setNow] = useState(Date.now); + useEffect(() => { + if (until === undefined || until <= Date.now()) return; + const timer = setInterval(() => { + const t = Date.now(); + setNow(t); + if (t >= until) clearInterval(timer); + }, 1000); + return () => clearInterval(timer); + }, [until]); + return now; +} + +const seconds = (ms: number) => + ms < 60_000 ? `${Math.ceil(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`; + +export function EndpointDetail({ row }: { row: EndpointRow }) { + const [view, setView] = useState<'stored' | 'returns'>('stored'); + const { record } = row; + return ( +
+ {record && ( + + )} + {view === 'returns' && record ? + + : } +
+ + {record?.args.length ? + + : null} + {row.meta && } +
+
+ ); +} + +/** What useSuspense() hands the component for this endpoint */ +function Returns({ record }: { record: EndpointRecord }) { + const controller = useController(); + const state = useContext(StateContext); + let data: unknown; + try { + data = controller.getResponse(record.endpoint, ...record.args, state).data; + } catch (e) { + data = e instanceof Error ? e.message : e; + } + return ; +} + +export function EntityDetail({ + row, + referrers, +}: { + row: EntityRow; + referrers: readonly Referrer[] | undefined; +}) { + return ( +
+ +
+ {row.meta && } + +
+
+ ); +} + +function ReferencedBy({ + referrers, +}: { + referrers: readonly Referrer[] | undefined; +}) { + return ( +
+ used by + {referrers?.length ? + + {referrers.map(r => ( + + ))} + + : nothing} +
+ ); +} diff --git a/website/src/components/Playground/preview/store/Sections.tsx b/website/src/components/Playground/preview/store/Sections.tsx new file mode 100644 index 000000000000..7e809226ae9b --- /dev/null +++ b/website/src/components/Playground/preview/store/Sections.tsx @@ -0,0 +1,246 @@ +import clsx from 'clsx'; +import React from 'react'; + +import type { EndpointRow, EntityTable, StoreModel } from './model'; +import { splitKey } from './model'; +import { plain } from './refs'; +import styles from './store.module.css'; +import { ROW_LIMIT, sectionId, showAllId, useStoreUI } from './StoreUI'; +import { Field } from './Value'; + +/** Every view shows the same sections; only how rows lay out differs */ +export function StoreSections({ + model, + endpoints, + renderTable, +}: { + model: StoreModel; + endpoints: React.ReactNode; + renderTable: (table: EntityTable) => React.ReactNode; +}) { + const entityCount = model.tables.reduce((n, t) => n + t.rows.length, 0); + return ( + <> + +
+ {endpoints} +
+
+ {model.tables.map(renderTable)} +
+ + + ); +} + +export function EndpointLabel({ + row, + open, +}: { + row: EndpointRow; + open: boolean; +}) { + return ( + <> + + {row.method}{' '} + {row.path} + + ); +} + +export function GroupLabel({ + table, + open, +}: { + table: EntityTable; + open: boolean; +}) { + return ( + <> + + {table.key} + {table.rows.length} + {table.kind === 'collection' && ( + Collection + )} + + ); +} + +export function Segmented({ + label, + options, + value, + onChange, +}: { + label: string; + options: Record; + value: V; + onChange: (value: V) => void; +}) { + return ( +
+ {(Object.keys(options) as V[]).map(v => ( + + ))} +
+ ); +} + +export function Chevron({ open }: { open: boolean }) { + return ( + ▶ + ); +} + +/** Collapsible block with a sticky header */ +export function Section({ + name, + title, + count, + children, +}: { + name: string; + title: string; + count?: number; + children: React.ReactNode; +}) { + const { isOpen, toggle } = useStoreUI(); + const id = sectionId(name); + const open = isOpen(id); + return ( +
+ + {open && children} +
+ ); +} + +/** Props making a row (tr or div) expand on click or Enter/Space */ +export function useRowProps(id: string, className?: string) { + const { isOpen, toggle, selected } = useStoreUI(); + const open = isOpen(id); + return { + open, + props: { + 'data-id': id, + tabIndex: 0, + 'aria-expanded': open, + className: clsx( + styles.row, + className, + selected === id && styles.selected, + ), + onClick: () => toggle(id), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(id); + } + }, + }, + }; +} + +/** Rows of a table, capped at ROW_LIMIT until "Show all" */ +export function useLimitedRows(tableKey: string, rows: readonly T[]) { + const { isOpen, toggle } = useStoreUI(); + const all = isOpen(showAllId(tableKey)); + const shown = all ? rows : rows.slice(0, ROW_LIMIT); + const footer = + rows.length > ROW_LIMIT ? + + : null; + return { shown, footer }; +} + +function OptimisticSection({ model }: { model: StoreModel }) { + if (!model.optimistic.length) return null; + return ( +
+ {model.optimistic.map((action, i) => ( + + ))} +
+ ); +} + +function OptimisticRow({ + action, + index, +}: { + action: StoreModel['optimistic'][number]; + index: number; +}) { + const { open, props } = useRowProps(`o\u001f${index}`); + const { method, path } = splitKey(action.key); + return ( + <> +
+ + {method} + {path} + + waiting for server + +
+ {open && ( +
+ + + +
+ )} + + ); +} + +/** Bookkeeping most people never need, collapsed by default */ +function InternalsSection({ model }: { model: StoreModel }) { + return ( +
+
+ + +
+
+ ); +} diff --git a/website/src/components/Playground/preview/store/StorePanel.tsx b/website/src/components/Playground/preview/store/StorePanel.tsx new file mode 100644 index 000000000000..91c18e30ba92 --- /dev/null +++ b/website/src/components/Playground/preview/store/StorePanel.tsx @@ -0,0 +1,109 @@ +import { StateContext, type State } from '@data-client/react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; + +import { buildModel, changedIds } from './model'; +import type SchemaRegistry from './schemaRegistry'; +import { Segmented } from './Sections'; +import styles from './store.module.css'; +import { StoreUIProvider } from './StoreUI'; +import TableView from './TableView'; +import TreeView from './TreeView'; +import { useTabStorage } from '../../../../utils/tabStorage'; + +type View = 'table' | 'tree'; +/** Rough width of one table column, used to decide how many fit */ +const COLUMN_WIDTH = 120; + +export default function StorePanel({ registry }: { registry: SchemaRegistry }) { + const state = useContext(StateContext); + const model = useMemo(() => buildModel(state, registry), [state, registry]); + const [stored, setView] = useTabStorage('playgroundStoreView'); + const view: View = stored === 'tree' ? 'tree' : 'table'; + + const scroller = useRef(null); + const maxColumns = useColumnCount(scroller); + useFlashChanges(scroller, state); + + const [pending, setPending] = useState(null); + useEffect(() => { + if (!pending || !scroller.current) return; + scrollToRow(scroller.current, pending); + setPending(null); + }, [pending]); + + return ( + +
+
+ +
+ {view === 'table' ? + + : } +
+
+ ); +} + +/** Table field columns that fit; re-renders only when the count changes */ +function useColumnCount(ref: React.RefObject) { + const [count, setCount] = useState(2); + useEffect(() => { + const el = ref.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(([entry]) => + setCount( + Math.max(1, Math.floor((entry.contentRect.width - 80) / COLUMN_WIDTH)), + ), + ); + observer.observe(el); + return () => observer.disconnect(); + }, [ref]); + return count; +} + +/** Briefly highlights rows whose stored value changed since the last state */ +function useFlashChanges( + ref: React.RefObject, + state: State, +) { + const prev = useRef(state); + useEffect(() => { + const before = prev.current; + prev.current = state; + const el = ref.current; + if (!el || before === state || typeof el.animate !== 'function') return; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + const ids = changedIds(before, state); + if (!ids.size) return; + for (const row of el.querySelectorAll('[data-id]')) { + if (!ids.has(row.dataset.id as string)) continue; + row.animate( + [ + { backgroundColor: 'var(--store-flash)' }, + { backgroundColor: 'transparent' }, + ], + { duration: 1400, easing: 'ease-out' }, + ); + } + }, [ref, state]); +} + +function scrollToRow(scroller: HTMLElement, id: string) { + const row = scroller.querySelector( + `[data-id="${CSS.escape(id)}"]`, + ); + if (!row) return; + const top = + row.getBoundingClientRect().top - + scroller.getBoundingClientRect().top + + scroller.scrollTop; + // leave room for the sticky toolbar and section header + scroller.scrollTo({ top: Math.max(0, top - 64), behavior: 'smooth' }); + row.focus({ preventScroll: true }); +} diff --git a/website/src/components/Playground/preview/store/StoreUI.tsx b/website/src/components/Playground/preview/store/StoreUI.tsx new file mode 100644 index 000000000000..2c8a2192be2c --- /dev/null +++ b/website/src/components/Playground/preview/store/StoreUI.tsx @@ -0,0 +1,102 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { isEndpointId, type StoreModel } from './model'; + +/** Rows shown per table before "Show all" */ +export const ROW_LIMIT = 8; + +interface StoreUIValue { + readonly isOpen: (id: string) => boolean; + readonly toggle: (id: string) => void; + readonly selected: string | null; + /** Opens everything leading to `id`, selects it and scrolls it into view */ + readonly reveal: (id: string) => void; +} + +const StoreUIContext = createContext({ + isOpen: () => false, + toggle: () => {}, + selected: null, + reveal: () => {}, +}); +export const useStoreUI = () => useContext(StoreUIContext); + +export const groupId = (key: string) => `g\u001f${key}`; +export const showAllId = (key: string) => `a\u001f${key}`; +export const sectionId = (name: string) => `s\u001f${name}`; + +/** Sections (but Internals) and the first few Entity types start open; + * rows start closed */ +const OPEN_GROUPS = 3; +const openSections = ['optimistic', 'endpoints', 'entities'].map(sectionId); + +export function StoreUIProvider({ + model, + onReveal, + children, +}: { + model: StoreModel; + onReveal: (id: string) => void; + children: React.ReactNode; +}) { + /** ids the user flipped away from their default */ + const [flipped, setFlipped] = useState>(new Set()); + const [selected, setSelected] = useState(null); + + const defaultOpen = useMemo( + () => + new Set([ + ...openSections, + ...model.tables.slice(0, OPEN_GROUPS).map(t => groupId(t.key)), + ]), + [model.tables], + ); + const isOpen = useCallback( + (id: string) => defaultOpen.has(id) !== flipped.has(id), + [defaultOpen, flipped], + ); + const toggle = useCallback((id: string) => { + setFlipped(prev => { + const next = new Set(prev); + if (!next.delete(id)) next.add(id); + return next; + }); + }, []); + + const reveal = useCallback( + (id: string) => { + const ancestors = [id]; + if (isEndpointId(id)) ancestors.push(sectionId('endpoints')); + for (const table of model.tables) { + const index = table.rows.findIndex(row => row.id === id); + if (index < 0) continue; + ancestors.push(sectionId('entities'), groupId(table.key)); + if (index >= ROW_LIMIT) ancestors.push(showAllId(table.key)); + } + setFlipped(prev => { + const next = new Set(prev); + for (const a of ancestors) + if (defaultOpen.has(a)) next.delete(a); + else next.add(a); + return next; + }); + setSelected(id); + onReveal(id); + }, + [model, defaultOpen, onReveal], + ); + + const value = useMemo( + () => ({ isOpen, toggle, selected, reveal }), + [isOpen, toggle, selected, reveal], + ); + return ( + {children} + ); +} diff --git a/website/src/components/Playground/preview/store/TableView.tsx b/website/src/components/Playground/preview/store/TableView.tsx new file mode 100644 index 000000000000..386d5d429ca7 --- /dev/null +++ b/website/src/components/Playground/preview/store/TableView.tsx @@ -0,0 +1,191 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { EndpointDetail, EntityDetail, Status } from './Details'; +import type { EndpointRow, EntityRow, EntityTable, StoreModel } from './model'; +import type { VNode } from './refs'; +import { + Chevron, + EndpointLabel, + GroupLabel, + StoreSections, + useLimitedRows, + useRowProps, +} from './Sections'; +import styles from './store.module.css'; +import { groupId, useStoreUI } from './StoreUI'; +import { Inline, prettyPk } from './Value'; + +/** Each Entity type is a table; fields that don't fit move into the row's + * expanded detail rather than scrolling sideways */ +export default function TableView({ + model, + maxColumns, +}: { + model: StoreModel; + maxColumns: number; +}) { + return ( + + + {model.endpoints.map(row => ( + + ))} + + + } + renderTable={table => ( + + )} + /> + ); +} + +function EndpointTableRow({ row }: { row: EndpointRow }) { + const { open, props } = useRowProps(row.id); + return ( + <> + + + + + + + + + + + + {open && ( + + + + + + )} + + ); +} + +const VALUE_COLUMN = ''; +const pkLabel = { + entity: 'id', + collection: 'args', + scalar: 'cell', + unknown: 'key', +} as const; + +function EntityGroup({ + table, + model, + maxColumns, +}: { + table: EntityTable; + model: StoreModel; + maxColumns: number; +}) { + const { isOpen, toggle } = useStoreUI(); + const open = isOpen(groupId(table.key)); + const { shown, footer } = useLimitedRows(table.key, table.rows); + // Entities get a column per field that fits; other tables one value column + const columns = + table.kind === 'entity' ? + table.fields.slice(0, maxColumns) + : [VALUE_COLUMN]; + const hidden = + table.kind === 'entity' ? table.fields.length - columns.length : 0; + return ( +
+ + {open && ( + <> + + + + + {columns.map(c => ( + + ))} + + + + {shown.map(row => ( + + ))} + +
+ {pkLabel[table.kind]} + {c}
+ {footer} + + )} +
+ ); +} + +function EntityTableRow({ + row, + columns, + model, +}: { + row: EntityRow; + columns: readonly string[]; + model: StoreModel; +}) { + const { open, props } = useRowProps(row.id); + return ( + <> + + + + {prettyPk(row.pk)} + + {columns.map(c => { + const node = c === VALUE_COLUMN ? row.value : field(row.value, c); + return {node && }; + })} + + {open && ( + + + + + + )} + + ); +} + +function field(node: VNode, name: string): VNode | undefined { + if (node.t !== 'obj') return; + return node.entries.find(([k]) => k === name)?.[1]; +} diff --git a/website/src/components/Playground/preview/store/TreeView.tsx b/website/src/components/Playground/preview/store/TreeView.tsx new file mode 100644 index 000000000000..979608ef9898 --- /dev/null +++ b/website/src/components/Playground/preview/store/TreeView.tsx @@ -0,0 +1,93 @@ +import React from 'react'; + +import { EndpointDetail, EntityDetail, Status } from './Details'; +import type { EndpointRow, EntityRow, EntityTable, StoreModel } from './model'; +import { + Chevron, + EndpointLabel, + GroupLabel, + StoreSections, + useLimitedRows, + useRowProps, +} from './Sections'; +import styles from './store.module.css'; +import { groupId } from './StoreUI'; +import { Inline, prettyPk } from './Value'; + +/** Explorer: one line per row with a preview of its fields */ +export default function TreeView({ model }: { model: StoreModel }) { + return ( + ( + + ))} + renderTable={table => ( + + )} + /> + ); +} + +function EndpointTreeRow({ row }: { row: EndpointRow }) { + const { open, props } = useRowProps(row.id); + return ( + <> +
+ + +
+ {open && } + + ); +} + +function EntityTreeGroup({ + table, + model, +}: { + table: EntityTable; + model: StoreModel; +}) { + const { open, props } = useRowProps(groupId(table.key)); + const { shown, footer } = useLimitedRows(table.key, table.rows); + return ( + <> +
+ +
+ {open && ( +
+ {shown.map(row => ( + + ))} + {footer} +
+ )} + + ); +} + +function EntityTreeRow({ row, model }: { row: EntityRow; model: StoreModel }) { + const { open, props } = useRowProps(row.id); + const preview = + row.value.t === 'obj' ? + { ...row.value, entries: row.value.entries.filter(([k]) => k !== 'id') } + : row.value; + return ( + <> +
+ + {prettyPk(row.pk)} + + + +
+ {open && ( +
+ +
+ )} + + ); +} diff --git a/website/src/components/Playground/preview/store/Value.tsx b/website/src/components/Playground/preview/store/Value.tsx new file mode 100644 index 000000000000..fdaa701a3234 --- /dev/null +++ b/website/src/components/Playground/preview/store/Value.tsx @@ -0,0 +1,223 @@ +import clsx from 'clsx'; +import React, { useState } from 'react'; + +import { entityId } from './model'; +import type { VNode } from './refs'; +import styles from './store.module.css'; +import { useStoreUI } from './StoreUI'; + +/** Items shown before a "N more" button in expanded values */ +const BLOCK_LIMIT = 20; +/** Items shown inline (table cells, tree previews) before "+N" */ +const INLINE_LIMIT = 3; + +export function RefChip({ + id, + label, + className, +}: { + id: string; + label: React.ReactNode; + className?: string; +}) { + const { reveal } = useStoreUI(); + return ( + + ); +} + +function EntityRef({ entityKey, pk }: { entityKey: string; pk: string }) { + return ( + + {entityKey} {prettyPk(pk)} + + } + /> + ); +} + +/** Single-line summary for table cells and tree rows */ +export function Inline({ + node, + name, + bare = false, +}: { + node: VNode; + name?: string; + /** Top-level object without braces, every field (the row truncates) */ + bare?: boolean; +}) { + switch (node.t) { + case 'ref': + return ; + case 'val': + return ; + case 'arr': { + const shown = node.items.slice(0, INLINE_LIMIT); + const rest = node.items.length - shown.length; + return ( + + [ + {shown.map((item, i) => ( + + {i > 0 && , } + + + ))} + {rest > 0 && , +{rest}} + ] + + ); + } + case 'obj': { + const shown = bare ? node.entries : node.entries.slice(0, 2); + const rest = node.entries.length - shown.length; + return ( + + {!bare && {'{'}} + {shown.map(([k, v], i) => ( + + {i > 0 && , } + {k} + : + + + ))} + {rest > 0 && , +{rest}} + {!bare && {'}'}} + + ); + } + } +} + +/** Full value: nested objects indent, long lists show more on request */ +export function Block({ node, name }: { node: VNode; name?: string }) { + if (node.t === 'obj') { + if (!node.entries.length) return {'{}'}; + return ( +
+ {node.entries.map(([k, v]) => ( + + ))} +
+ ); + } + if (node.t === 'arr') return ; + return ; +} + +export function Field({ name, node }: { name: string; node: VNode }) { + const nested = + (node.t === 'obj' && node.entries.length > 0) || + (node.t === 'arr' && node.items.some(i => i.t === 'obj')); + return ( +
+ + {name} + : + + +
+ ); +} + +function BlockList({ node }: { node: Extract }) { + const [all, setAll] = useState(false); + const shown = all ? node.items : node.items.slice(0, BLOCK_LIMIT); + const rest = node.items.length - shown.length; + const more = + rest > 0 ? + + : null; + if (!node.items.length) return []; + // Objects get one indexed block each; refs and primitives wrap inline + if (node.items.some(i => i.t === 'obj')) + return ( +
+ {shown.map((item, i) => ( + + ))} + {more} +
+ ); + return ( + + {shown.map((item, i) => ( + + ))} + {more} + + ); +} + +function Primitive({ value, name }: { value: unknown; name?: string }) { + if (value === null || value === undefined) + return {String(value)}; + if (typeof value === 'string') + return {JSON.stringify(value)}; + if (typeof value === 'number' && value && name && isTimeField(name)) + return ( + + {formatTime(value)} + + ); + if (value instanceof Date) + return {value.toISOString()}; + if (typeof value === 'object') + return {JSON.stringify(value)}; + return {String(value)}; +} + +const isTimeField = (name: string) => + name === 'date' || name === 'lastReset' || /[a-z]At$/.test(name); + +const timeFormatter = + typeof Intl !== 'undefined' ? + Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + fractionalSecondDigits: 3, + }) + : undefined; + +export function formatTime(ms: number) { + if (!isFinite(ms)) return String(ms); + return timeFormatter ? timeFormatter.format(ms) : new Date(ms).toISOString(); +} + +/** Collection pks are serialized args: `{"userId":"1"}` → `userId: 1` */ +export function prettyPk(pk: string) { + if (!pk.startsWith('{')) return pk; + try { + const args = JSON.parse(pk); + const entries = Object.entries(args); + return entries.length ? + entries.map(([k, v]) => `${k}: ${String(v)}`).join(', ') + : 'all'; + } catch { + return pk; + } +} diff --git a/website/src/components/Playground/preview/store/model.ts b/website/src/components/Playground/preview/store/model.ts new file mode 100644 index 000000000000..af9440ed11ea --- /dev/null +++ b/website/src/components/Playground/preview/store/model.ts @@ -0,0 +1,158 @@ +import { schema as s } from '@data-client/endpoint'; +import type { State } from '@data-client/react'; + +import { forEachRef, resolve, resolveRow, type VNode } from './refs'; +import type { + EndpointRecord, + default as SchemaRegistry, +} from './schemaRegistry'; + +/** Row ids shared by both views, selection and flashes */ +const endpointId = (key: string) => `e\u001f${key}`; +export const isEndpointId = (id: string) => id.startsWith('e\u001f'); +export const entityId = (key: string, pk: string) => `n\u001f${key}\u001f${pk}`; + +export interface EndpointRow { + readonly id: string; + readonly key: string; + readonly method: string; + readonly path: string; + readonly value: VNode; + readonly meta: State['meta'][string] | undefined; + /** Known only once the endpoint's action passed through the registry */ + readonly record: EndpointRecord | undefined; +} + +export interface EntityRow { + readonly id: string; + readonly pk: string; + readonly value: VNode; + readonly meta: State['entitiesMeta'][string][string] | undefined; +} + +export interface EntityTable { + readonly key: string; + readonly kind: 'entity' | 'collection' | 'scalar' | 'unknown'; + readonly rows: readonly EntityRow[]; + /** Field names in first-seen order, without `id` (entities only) */ + readonly fields: readonly string[]; +} + +export interface Referrer { + readonly id: string; + readonly label: string; +} + +export interface StoreModel { + readonly endpoints: readonly EndpointRow[]; + readonly tables: readonly EntityTable[]; + /** Row id → everything that references it */ + readonly referrers: ReadonlyMap; + readonly optimistic: State['optimistic']; + readonly indexes: State['indexes']; + readonly lastReset: number; +} + +export function buildModel( + state: State, + registry: SchemaRegistry, +): StoreModel { + const referrers = new Map(); + const addRefs = (from: Referrer, node: VNode) => + forEachRef(node, (key, pk) => { + const to = entityId(key, pk); + const list = referrers.get(to); + if (!list) referrers.set(to, [from]); + // a row's refs arrive together, so a repeat is always the last entry + else if (list[list.length - 1].id !== from.id) list.push(from); + }); + + const endpoints = Object.entries(state.endpoints).map(([key, raw]) => { + const record = registry.endpoints.get(key); + const value = resolve(raw, record?.endpoint.schema); + const row: EndpointRow = { + id: endpointId(key), + key, + ...splitKey(key), + value, + meta: state.meta[key], + record, + }; + addRefs({ id: row.id, label: `${row.method} ${row.path}`.trim() }, value); + return row; + }); + + const tables = Object.entries(state.entities).map(([key, rows = {}]) => { + const table = registry.entities.get(key); + const kind = tableKind(table, rows); + const fields = new Set(); + const entityRows = Object.entries(rows).map(([pk, raw]) => { + if (kind === 'entity' && raw && typeof raw === 'object') + Object.keys(raw).forEach(f => f !== 'id' && fields.add(f)); + const row: EntityRow = { + id: entityId(key, pk), + pk, + value: resolveRow(raw, table), + meta: state.entitiesMeta[key]?.[pk], + }; + addRefs({ id: row.id, label: `${key} ${pk}` }, row.value); + return row; + }); + return { key, kind, rows: entityRows, fields: [...fields] }; + }); + // Entities first, then Collections, then Scalar cells + const order = { entity: 0, unknown: 1, collection: 2, scalar: 3 }; + tables.sort((a, b) => order[a.kind] - order[b.kind]); + + return { + endpoints, + tables, + referrers, + optimistic: state.optimistic, + indexes: state.indexes, + lastReset: state.lastReset, + }; +} + +function tableKind( + table: any, + rows: Record, +): EntityTable['kind'] { + if (typeof table === 'function') return 'entity'; + if (table instanceof s.Collection) return 'collection'; + if (table instanceof s.Scalar) return 'scalar'; + // Unknown schema (e.g. state seeded without an action): guess from the rows + const first = Object.values(rows)[0]; + return Array.isArray(first) ? 'collection' : 'unknown'; +} + +/** `GET https://host/posts?x=1` → method and path (host dropped) */ +export function splitKey(key: string) { + const match = /^([A-Z]+) (\S+)(.*)$/.exec(key); + if (!match) return { method: '', path: key }; + const [, method, url, rest] = match; + try { + const parsed = new URL(url); + return { method, path: `${parsed.pathname}${parsed.search}${rest}` }; + } catch { + return { method, path: `${url}${rest}` }; + } +} + +/** Ids whose stored value changed between two states */ +export function changedIds(prev: State, next: State) { + const ids = new Set(); + if (prev === next) return ids; + for (const [key, value] of Object.entries(next.endpoints)) { + if (prev.endpoints[key] !== value || prev.meta[key] !== next.meta[key]) + ids.add(endpointId(key)); + } + for (const [key, rows] of Object.entries(next.entities)) { + const before = prev.entities[key]; + if (before === rows || !rows) continue; + for (const [pk, row] of Object.entries(rows)) { + if (before?.[pk] !== row) ids.add(entityId(key, pk)); + } + } + return ids; +} diff --git a/website/src/components/Playground/preview/store/refs.ts b/website/src/components/Playground/preview/store/refs.ts new file mode 100644 index 000000000000..07357f4cbbae --- /dev/null +++ b/website/src/components/Playground/preview/store/refs.ts @@ -0,0 +1,135 @@ +import { schema as s } from '@data-client/endpoint'; + +/** A stored value annotated with which parts are references to entities */ +export type VNode = + | { readonly t: 'ref'; readonly key: string; readonly pk: string } + | { readonly t: 'arr'; readonly items: readonly VNode[] } + | { + readonly t: 'obj'; + readonly entries: readonly (readonly [string, VNode])[]; + } + | { readonly t: 'val'; readonly v: unknown }; + +/** Anything stored as its own table in `state.entities` (Entity, Collection, + * Scalar); the same test denormalize uses */ +export function isEntityLike(schema: any): boolean { + return ( + typeof schema?.createIfValid === 'function' && + typeof schema.key === 'string' + ); +} + +function isPlainObject(value: any): value is Record { + if (!value || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function isPolymorphic(schema: any) { + return ( + schema instanceof s.Array || + schema instanceof s.Values || + schema instanceof s.Union || + schema instanceof s.Invalidate + ); +} + +/** Calls `fn` with each schema nested directly inside `schema` */ +export function forEachChildSchema(schema: any, fn: (child: any) => void) { + if (Array.isArray(schema)) return schema.forEach(fn); + if (isPlainObject(schema)) return Object.values(schema).forEach(fn); + // Entity fields, Collection/Array/Values/Union members, Object fields, Lazy + if (schema.schema && !(schema instanceof s.Scalar)) fn(schema.schema); +} + +/** Annotates a normalized `value` stored for `schema` */ +export function resolve(value: unknown, schema: any): VNode { + if (value == null || schema == null) return plain(value); + if (Array.isArray(schema)) + return Array.isArray(value) ? + { t: 'arr', items: value.map(v => resolve(v, schema[0])) } + : plain(value); + if (schema instanceof s.Scalar) return plain(value); + if (isEntityLike(schema)) + return typeof value === 'string' || typeof value === 'number' ? + { t: 'ref', key: schema.key, pk: `${value}` } + : plain(value); + if (isPolymorphic(schema)) { + const member = (v: unknown) => resolveMember(v, schema); + if (schema instanceof s.Values) + return isPlainObject(value) ? + { + t: 'obj', + entries: Object.entries(value).map(([k, v]) => [k, member(v)]), + } + : plain(value); + if (schema instanceof s.Array) + return Array.isArray(value) ? + { t: 'arr', items: value.map(member) } + : plain(value); + return member(value); + } + if (schema instanceof s.Lazy) return resolve(value, schema.schema); + const fields = + isPlainObject(schema) ? schema + : schema instanceof s.Object ? schema.schema + : undefined; + if (fields && isPlainObject(value)) return resolveFields(value, fields); + return plain(value); +} + +/** One member of an Array/Values/Union: discriminated ones store `{ id, schema }` */ +function resolveMember(value: any, schema: any): VNode { + if (schema.isSingleSchema) return resolve(value, schema.schema); + if (isPlainObject(value) && 'schema' in value && 'id' in value) { + const member = schema.schema[value.schema as string]; + if (member) return resolve(value.id, member); + } + return plain(value); +} + +function resolveFields( + value: Record, + fields: Record | undefined, +): VNode { + return { + t: 'obj', + entries: Object.entries(value).map(([k, v]) => [ + k, + resolve(v, fields?.[k]), + ]), + }; +} + +/** Annotates one stored row of `state.entities[key]` */ +export function resolveRow(row: unknown, table: any): VNode { + if (!table) return plain(row); + if (table instanceof s.Collection) return resolve(row, table.schema); + if (typeof table === 'function' && isPlainObject(row)) + return resolveFields(row, table.schema); + return plain(row); +} + +export function plain(value: unknown): VNode { + if (Array.isArray(value)) return { t: 'arr', items: value.map(plain) }; + if (value && typeof value === 'object' && !(value instanceof Date)) + return { + t: 'obj', + entries: Object.entries(value).map(([k, v]) => [k, plain(v)]), + }; + return { t: 'val', v: value }; +} + +export function forEachRef( + node: VNode, + fn: (key: string, pk: string) => void, +): void { + switch (node.t) { + case 'ref': + return fn(node.key, node.pk); + case 'arr': + return node.items.forEach(n => forEachRef(n, fn)); + case 'obj': + return node.entries.forEach(([, n]) => forEachRef(n, fn)); + } +} diff --git a/website/src/components/Playground/preview/store/schemaRegistry.ts b/website/src/components/Playground/preview/store/schemaRegistry.ts new file mode 100644 index 000000000000..c9ab23ebaaf3 --- /dev/null +++ b/website/src/components/Playground/preview/store/schemaRegistry.ts @@ -0,0 +1,52 @@ +import { + actionTypes, + type ActionTypes, + type Manager, + type Middleware, +} from '@data-client/react'; + +import { forEachChildSchema, isEntityLike } from './refs'; + +export interface EndpointRecord { + readonly endpoint: any; + readonly args: readonly unknown[]; +} + +/** Remembers each endpoint (and every entity schema reachable from it) as + * actions pass through, since the store only holds keys and ids. + * Lives in the preview's DataProvider; action tracking can hook in here too. */ +export default class SchemaRegistry implements Manager { + readonly endpoints = new Map(); + /** Entity table key (`state.entities[key]`) → Entity class, Collection or Scalar */ + readonly entities = new Map(); + + middleware: Middleware = () => next => action => { + switch (action.type) { + case actionTypes.FETCH: + case actionTypes.SET_RESPONSE: + case actionTypes.OPTIMISTIC: + this.endpoints.set(action.key, { + endpoint: action.endpoint, + args: action.args, + }); + this.learn(action.endpoint.schema); + break; + case actionTypes.SET: + this.learn(action.schema); + break; + } + return next(action); + }; + + cleanup() {} + + learn(schema: any) { + if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) + return; + if (isEntityLike(schema)) { + if (this.entities.get(schema.key) === schema) return; + this.entities.set(schema.key, schema); + } + forEachChildSchema(schema, child => this.learn(child)); + } +} diff --git a/website/src/components/Playground/preview/store/store.module.css b/website/src/components/Playground/preview/store/store.module.css new file mode 100644 index 000000000000..3cb653f4358f --- /dev/null +++ b/website/src/components/Playground/preview/store/store.module.css @@ -0,0 +1,387 @@ +/* Store inspector: code-colored in both themes, like the editor */ +.store { + --store-fg: rgb(191, 199, 213); + --store-dim: rgb(105, 112, 152); + --store-key: rgb(130, 170, 255); + --store-string: rgb(195, 232, 141); + --store-number: rgb(247, 140, 108); + --store-null: rgb(255, 88, 116); + --store-type: #c792ea; + --store-fresh: #7fd1a0; + --store-stale: #ffcb6b; + --store-line: rgba(255, 255, 255, 0.07); + --store-hover: rgba(255, 255, 255, 0.045); + --store-selected: rgba(130, 170, 255, 0.16); + --store-flash: rgba(255, 203, 107, 0.3); + --store-bg: var(--monoco-code-background); + + flex: 4 1 40%; + min-width: 0; + max-height: 32rem; + overflow: auto; + overscroll-behavior: contain; + background: var(--store-bg); + color: var(--store-fg); + font: 12px / 1.6 var(--ifm-font-family-monospace); +} + +.toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + justify-content: flex-end; + align-items: center; + box-sizing: border-box; + height: 2rem; + padding: 0 0.5rem; + background: var(--store-bg); +} + +.seg { + display: inline-flex; + border: 1px solid var(--store-line); + border-radius: 5px; + overflow: hidden; +} +.seg button { + font: 500 11px / 1.6 var(--ifm-font-family-base); + background: none; + border: 0; + color: var(--store-dim); + padding: 0.05rem 0.6rem; + cursor: pointer; +} +.seg button[aria-pressed='true'] { + background: rgba(130, 170, 255, 0.16); + color: var(--store-fg); +} + +.section + .section { + border-top: 1px solid var(--store-line); +} +.sectionHeader { + position: sticky; + top: 2rem; + z-index: 1; + display: flex; + align-items: center; + gap: 0.45rem; + width: 100%; + padding: 0.35rem 0.75rem; + border: 0; + background: var(--store-bg); + color: var(--store-dim); + font: 600 10.5px / 1.6 var(--ifm-font-family-base); + letter-spacing: 0.09em; + text-transform: uppercase; + text-align: start; + cursor: pointer; +} +.count { + font: 400 11px var(--ifm-font-family-monospace); + letter-spacing: 0; + color: var(--store-dim); +} + +.chevron { + display: inline-block; + flex: none; + width: 1em; + font-size: 8px; + color: var(--store-dim); + transition: transform 150ms; +} +.chevronOpen { + transform: rotate(90deg); +} + +.row { + cursor: pointer; + outline-offset: -2px; +} +div.row { + display: flex; + align-items: baseline; + gap: 0.45rem; + min-width: 0; + padding: 0.1rem 0.75rem; +} +.row:focus-visible { + outline: 2px solid var(--store-key); +} +.selected, +.selected > td { + background: var(--store-selected); +} +@media (hover: hover) { + .row:hover, + .row:hover > td, + .sectionHeader:hover, + .groupHeader:hover { + background-color: var(--store-hover); + } +} + +.indent { + padding-left: 1.1rem; +} + +/* tables: fixed layout so long values truncate instead of scrolling */ +.table { + display: table; + width: 100%; + margin: 0; + table-layout: fixed; + border-collapse: collapse; + font: inherit; +} +.table thead, +.table tr, +.table tr:nth-child(2n), +.table td, +.table th { + border: 0; + background: none; +} +.table th { + padding: 0.1rem 0.5rem 0.1rem 0; + text-align: start; + font: 600 10px / 1.6 var(--ifm-font-family-base); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--store-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.table th:first-child { + padding-left: 0.75rem; +} +.table td { + padding: 0.1rem 0.5rem 0.1rem 0; + border-top: 1px solid var(--store-line); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: baseline; +} +.table td:first-child { + padding-left: 0.75rem; +} +.table tr.detailRow > td { + border-top: 0; + white-space: normal; + overflow: visible; +} +.pkCell { + width: 6.5em; +} +.argsCell { + width: 40%; +} +.endpointCell { + width: 45%; +} +.statusCell { + width: 7em; + text-align: end; +} + +.group { + margin-bottom: 0.25rem; +} +.groupHeader { + display: flex; + align-items: baseline; + gap: 0.45rem; + width: 100%; + padding: 0.15rem 0.75rem; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; +} +.hint { + margin-left: auto; + color: var(--store-dim); + font-size: 11px; + white-space: nowrap; +} +.kind { + color: var(--store-dim); + font-size: 11px; +} +.showAll, +.more { + border: 0; + background: none; + padding: 0; + font: inherit; + font-size: 11px; + color: var(--store-key); + cursor: pointer; +} +.showAll { + display: block; + padding: 0.15rem 0.75rem 0.2rem 2rem; +} +.showAll:hover, +.more:hover { + text-decoration: underline; +} + +/* expanded rows */ +.detail { + padding: 0.15rem 0.75rem 0.45rem 2rem; +} +.indent .detail { + padding-left: 1rem; +} +.detailRow .detail { + padding-left: 1.25rem; + padding-right: 0; +} +.detail > .seg { + display: flex; + width: fit-content; + margin-bottom: 0.25rem; +} +.fields { + flex-basis: 100%; +} +.field { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0 0.4rem; + min-width: 0; +} +.field.nested > .fields { + padding-left: 1rem; +} +.metaList { + margin-top: 0.3rem; + padding-top: 0.25rem; + border-top: 1px dashed var(--store-line); +} +.metaList .key, +.label { + color: var(--store-dim); +} +.wrapList { + display: inline-flex; + flex-wrap: wrap; + gap: 0.25rem 0.3rem; + align-items: baseline; + min-width: 0; +} + +/* values */ +.key { + color: var(--store-key); +} +.type { + color: var(--store-type); +} +.string { + color: var(--store-string); + overflow-wrap: anywhere; +} +.number { + color: var(--store-number); +} +.null { + color: var(--store-null); +} +.dim { + color: var(--store-dim); +} +.trunc { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.inlineList { + white-space: nowrap; +} +.method { + flex: none; + font: 600 10px var(--ifm-font-family-base); + letter-spacing: 0.04em; + color: var(--store-fresh); +} + +.ref { + display: inline-flex; + align-items: baseline; + gap: 0.3em; + max-width: 100%; + padding: 0 0.35em; + border: 1px solid rgba(199, 146, 234, 0.32); + border-radius: 4px; + background: rgba(199, 146, 234, 0.12); + color: var(--store-type); + font: inherit; + font-size: 11px; + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +.ref b { + font-weight: 500; + color: var(--store-fg); +} +.ref:hover { + background: rgba(199, 146, 234, 0.24); +} +.ref:focus-visible { + outline: 2px solid var(--store-key); +} +.endpointRef { + color: var(--store-fresh); + border-color: rgba(127, 209, 160, 0.35); + background: rgba(127, 209, 160, 0.08); +} +.endpointRef:hover { + background: rgba(127, 209, 160, 0.18); +} + +.pill { + flex: none; + margin-left: auto; + padding: 0 0.5em; + border: 1px solid; + border-radius: 999px; + font: 500 10px / 1.6 var(--ifm-font-family-base); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.fresh { + color: var(--store-fresh); + border-color: rgba(127, 209, 160, 0.4); +} +.stale { + color: var(--store-stale); + border-color: rgba(255, 203, 107, 0.4); +} +.error { + color: var(--store-null); + border-color: rgba(255, 88, 116, 0.4); +} +.optimistic { + color: var(--store-type); + border-color: rgba(199, 146, 234, 0.4); +} + +@media (prefers-reduced-motion: reduce) { + .chevron { + transition: none; + } +} diff --git a/website/src/components/Playground/styles.module.css b/website/src/components/Playground/styles.module.css index bb55a52b6907..da860732fe09 100644 --- a/website/src/components/Playground/styles.module.css +++ b/website/src/components/Playground/styles.module.css @@ -578,3 +578,26 @@ div.fixtureJson { color: inherit; cursor: pointer; } + +/* Narrow playgrounds: the Store stacks under the preview at full width */ +@container playground (max-width: 559px) { + .playgroundResult { + flex-direction: column; + } + .playgroundResult > * { + flex: none; + } + .debugToggle { + writing-mode: horizontal-tb; + text-orientation: mixed; + letter-spacing: 0.08rem; + line-height: inherit; + padding: 0.3rem 0.75rem; + } + .debugToggle .arrow.right { + transform: rotateZ(90deg); + } + .debugToggle .arrow.left { + transform: rotateZ(0deg); + } +} diff --git a/yarn.lock b/yarn.lock index ff0acfa90acf..ce65e1cd936e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23953,7 +23953,6 @@ __metadata: raw-loader: "npm:^4.0.2" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" - react-json-tree: "npm:0.20.0" react-live: "npm:^4.0.0" react-redux: "npm:^9.2.0" redux: "npm:^5.0.1" @@ -24141,7 +24140,7 @@ __metadata: languageName: node linkType: hard -"react-json-tree@npm:0.20.0, react-json-tree@npm:^0.20.0": +"react-json-tree@npm:^0.20.0": version: 0.20.0 resolution: "react-json-tree@npm:0.20.0" dependencies: From 5fc3d34bfc0b9c4393f9ac41c334076452e264fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:33:41 +0000 Subject: [PATCH 02/28] fix(website): Keep Store groups open or closed as tables arrive Store explicit open/closed choices instead of flips against a default that moved whenever a new Entity type sorted in. Each group's default is now fixed when it first appears. Covers the remaining schema types in tests and notes the registry never prunes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SaF7H7PxLeSAoqV55xaXL3 --- .../preview/__tests__/StoreUI.test.tsx | 41 ++++++++++++ .../preview/__tests__/storeModel.test.ts | 34 ++++++++++ .../Playground/preview/store/StoreUI.tsx | 67 ++++++++++--------- .../preview/store/schemaRegistry.ts | 4 +- 4 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 website/src/components/Playground/preview/__tests__/StoreUI.test.tsx diff --git a/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx new file mode 100644 index 000000000000..96d9efa5779d --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx @@ -0,0 +1,41 @@ +/// +import { act, render } from '@testing-library/react'; +import React from 'react'; + +import type { EntityTable, StoreModel } from '../store/model'; +import { groupId, StoreUIProvider, useStoreUI } from '../store/StoreUI'; + +const table = (key: string): EntityTable => ({ + key, + kind: 'entity', + rows: [], + fields: [], +}); +const model = (...keys: string[]) => + ({ endpoints: [], tables: keys.map(table) }) as unknown as StoreModel; + +let ui: ReturnType; +function Probe() { + ui = useStoreUI(); + return null; +} +const mount = (m: StoreModel) => ( + {}}> + + +); + +describe('StoreUI', () => { + it('keeps groups open or closed when tables arrive later', () => { + const { rerender } = render(mount(model('User', 'Post', '[Comment]'))); + expect(ui.isOpen(groupId('[Comment]'))).toBe(true); + act(() => ui.toggle(groupId('Post'))); + expect(ui.isOpen(groupId('Post'))).toBe(false); + + // a new Entity type sorts ahead of the Collection + rerender(mount(model('User', 'Post', 'Comment', '[Comment]'))); + expect(ui.isOpen(groupId('Post'))).toBe(false); + expect(ui.isOpen(groupId('[Comment]'))).toBe(true); + expect(ui.isOpen(groupId('Comment'))).toBe(false); + }); +}); diff --git a/website/src/components/Playground/preview/__tests__/storeModel.test.ts b/website/src/components/Playground/preview/__tests__/storeModel.test.ts index 532cce30e93f..3b593f27a938 100644 --- a/website/src/components/Playground/preview/__tests__/storeModel.test.ts +++ b/website/src/components/Playground/preview/__tests__/storeModel.test.ts @@ -73,6 +73,40 @@ describe('store model', () => { }); }); + it('resolves Values, Object, Lazy and Invalidate members', () => { + const ref = (key: string, pk: string) => ({ t: 'ref', key, pk }); + expect(resolve({ a: '123' }, new schema.Values(User))).toEqual({ + t: 'obj', + entries: [['a', ref('User', '123')]], + }); + expect( + resolve({ user: '123', n: 1 }, new schema.Object({ user: User })), + ).toEqual({ + t: 'obj', + entries: [ + ['user', ref('User', '123')], + ['n', { t: 'val', v: 1 }], + ], + }); + expect(resolve(['123'], new schema.Lazy([User]))).toEqual({ + t: 'arr', + items: [ref('User', '123')], + }); + expect(resolve('1', new schema.Invalidate(Post))).toEqual(ref('Post', '1')); + }); + + it('shows Scalar cells as plain values', () => { + const scalar = new schema.Scalar({ + lens: (args: any) => args[0]?.portfolio, + key: 'portfolio', + entity: User, + }); + expect(resolve(['1', 'pct', 'User'], scalar)).toEqual({ + t: 'arr', + items: ['1', 'pct', 'User'].map(v => ({ t: 'val', v })), + }); + }); + it('indexes who references each entity', () => { const model = buildModel( state, diff --git a/website/src/components/Playground/preview/store/StoreUI.tsx b/website/src/components/Playground/preview/store/StoreUI.tsx index 2c8a2192be2c..b0baae9dc9dd 100644 --- a/website/src/components/Playground/preview/store/StoreUI.tsx +++ b/website/src/components/Playground/preview/store/StoreUI.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useContext, useMemo, + useRef, useState, } from 'react'; @@ -31,10 +32,12 @@ export const groupId = (key: string) => `g\u001f${key}`; export const showAllId = (key: string) => `a\u001f${key}`; export const sectionId = (name: string) => `s\u001f${name}`; -/** Sections (but Internals) and the first few Entity types start open; - * rows start closed */ +/** Sections (but Internals) and the first few Entity types to appear start + * open; rows start closed */ const OPEN_GROUPS = 3; -const openSections = ['optimistic', 'endpoints', 'entities'].map(sectionId); +const openSections = new Set( + ['optimistic', 'endpoints', 'entities'].map(sectionId), +); export function StoreUIProvider({ model, @@ -45,29 +48,39 @@ export function StoreUIProvider({ onReveal: (id: string) => void; children: React.ReactNode; }) { - /** ids the user flipped away from their default */ - const [flipped, setFlipped] = useState>(new Set()); + /** the user's explicit open/closed choices */ + const [overrides, setOverrides] = useState>( + new Map(), + ); const [selected, setSelected] = useState(null); - const defaultOpen = useMemo( - () => - new Set([ - ...openSections, - ...model.tables.slice(0, OPEN_GROUPS).map(t => groupId(t.key)), - ]), - [model.tables], - ); + // A group's default is fixed when it first appears, so tables arriving + // later (Suspense loads them one by one) never reshuffle what is open + const groupDefaults = useRef(new Map()).current; + for (const table of model.tables) { + const id = groupId(table.key); + if (!groupDefaults.has(id)) + groupDefaults.set(id, groupDefaults.size < OPEN_GROUPS); + } + const isOpen = useCallback( - (id: string) => defaultOpen.has(id) !== flipped.has(id), - [defaultOpen, flipped], + (id: string) => + overrides.get(id) ?? (openSections.has(id) || !!groupDefaults.get(id)), + [overrides, groupDefaults], + ); + const setOpen = useCallback( + (ids: readonly string[], open: boolean) => + setOverrides(prev => { + const next = new Map(prev); + for (const id of ids) next.set(id, open); + return next; + }), + [], + ); + const toggle = useCallback( + (id: string) => setOpen([id], !isOpen(id)), + [setOpen, isOpen], ); - const toggle = useCallback((id: string) => { - setFlipped(prev => { - const next = new Set(prev); - if (!next.delete(id)) next.add(id); - return next; - }); - }, []); const reveal = useCallback( (id: string) => { @@ -79,17 +92,11 @@ export function StoreUIProvider({ ancestors.push(sectionId('entities'), groupId(table.key)); if (index >= ROW_LIMIT) ancestors.push(showAllId(table.key)); } - setFlipped(prev => { - const next = new Set(prev); - for (const a of ancestors) - if (defaultOpen.has(a)) next.delete(a); - else next.add(a); - return next; - }); + setOpen(ancestors, true); setSelected(id); onReveal(id); }, - [model, defaultOpen, onReveal], + [model, setOpen, onReveal], ); const value = useMemo( diff --git a/website/src/components/Playground/preview/store/schemaRegistry.ts b/website/src/components/Playground/preview/store/schemaRegistry.ts index c9ab23ebaaf3..47f22ceb884e 100644 --- a/website/src/components/Playground/preview/store/schemaRegistry.ts +++ b/website/src/components/Playground/preview/store/schemaRegistry.ts @@ -14,7 +14,9 @@ export interface EndpointRecord { /** Remembers each endpoint (and every entity schema reachable from it) as * actions pass through, since the store only holds keys and ids. - * Lives in the preview's DataProvider; action tracking can hook in here too. */ + * Lives in the preview's DataProvider; extend this manager (rather than + * adding another) when the preview starts tracking actions. + * Never prunes: fine for a playground session, not for a long-lived app. */ export default class SchemaRegistry implements Manager { readonly endpoints = new Map(); /** Entity table key (`state.entities[key]`) → Entity class, Collection or Scalar */ From 90208ba71ebb909311db9e37320bee46da4a2794 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:36:24 +0000 Subject: [PATCH 03/28] test(website): Cover the Store inspector's model, registry and reveal Brings branch coverage back over the repo's 96% threshold. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SaF7H7PxLeSAoqV55xaXL3 --- .../preview/__tests__/StoreUI.test.tsx | 39 +++++++++++- .../preview/__tests__/storeModel.test.ts | 62 ++++++++++++++++++- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx index 96d9efa5779d..fceae22e054e 100644 --- a/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx +++ b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx @@ -3,7 +3,15 @@ import { act, render } from '@testing-library/react'; import React from 'react'; import type { EntityTable, StoreModel } from '../store/model'; -import { groupId, StoreUIProvider, useStoreUI } from '../store/StoreUI'; +import { entityId } from '../store/model'; +import { + groupId, + ROW_LIMIT, + sectionId, + showAllId, + StoreUIProvider, + useStoreUI, +} from '../store/StoreUI'; const table = (key: string): EntityTable => ({ key, @@ -19,8 +27,9 @@ function Probe() { ui = useStoreUI(); return null; } +const onReveal = jest.fn(); const mount = (m: StoreModel) => ( - {}}> + ); @@ -38,4 +47,30 @@ describe('StoreUI', () => { expect(ui.isOpen(groupId('[Comment]'))).toBe(true); expect(ui.isOpen(groupId('Comment'))).toBe(false); }); + + it('reveal opens everything leading to a row', () => { + const rows = Array.from({ length: ROW_LIMIT + 1 }, (_, i) => ({ + id: entityId('Late', `${i}`), + })); + const m = { + endpoints: [], + tables: ['A', 'B', 'C'].map(table).concat({ ...table('Late'), rows }), + } as unknown as StoreModel; + render(mount(m)); + act(() => ui.toggle(sectionId('entities'))); + const target = entityId('Late', `${ROW_LIMIT}`); + act(() => ui.reveal(target)); + expect(ui.selected).toBe(target); + expect(onReveal).toHaveBeenCalledWith(target); + for (const id of [ + target, + sectionId('entities'), + groupId('Late'), + showAllId('Late'), + ]) + expect(ui.isOpen(id)).toBe(true); + + act(() => ui.reveal('e\u001fGET /x')); + expect(ui.isOpen(sectionId('endpoints'))).toBe(true); + }); }); diff --git a/website/src/components/Playground/preview/__tests__/storeModel.test.ts b/website/src/components/Playground/preview/__tests__/storeModel.test.ts index 3b593f27a938..d51b545c5e3e 100644 --- a/website/src/components/Playground/preview/__tests__/storeModel.test.ts +++ b/website/src/components/Playground/preview/__tests__/storeModel.test.ts @@ -1,8 +1,9 @@ /// import { Collection, Entity, schema } from '@data-client/endpoint'; -import type { State } from '@data-client/react'; +import { actionTypes, type State } from '@data-client/react'; import { buildModel, changedIds, entityId, splitKey } from '../store/model'; +import { resolveRow } from '../store/refs'; import { resolve } from '../store/refs'; import SchemaRegistry from '../store/schemaRegistry'; @@ -107,6 +108,31 @@ describe('store model', () => { }); }); + it('falls back to plain values when the stored shape does not match', () => { + const val = (v: unknown) => ({ t: 'val', v }); + expect(resolve('x', new schema.Values(User))).toEqual(val('x')); + expect(resolve('x', new schema.Array(User))).toEqual(val('x')); + expect(resolve('x', [User])).toEqual(val('x')); + expect(resolve({ id: '1' }, User)).toEqual({ + t: 'obj', + entries: [['id', val('1')]], + }); + const union = new schema.Union({ users: User }, (i: any) => i.type); + expect(resolve({ id: '1', schema: 'nope' }, union)).toEqual({ + t: 'obj', + entries: [ + ['id', val('1')], + ['schema', val('nope')], + ], + }); + expect(resolveRow('x', undefined)).toEqual(val('x')); + expect(resolveRow('x', User)).toEqual(val('x')); + expect(resolveRow(['249'], Post.schema.comments)).toEqual({ + t: 'arr', + items: [{ t: 'ref', key: 'Comment', pk: '249' }], + }); + }); + it('indexes who references each entity', () => { const model = buildModel( state, @@ -134,6 +160,36 @@ describe('store model', () => { expect(model.referrers.size).toBe(0); }); + it('guesses table kinds without a schema', () => { + const model = buildModel( + { + ...state, + entities: { Thing: { a: { n: 1 } }, '[Thing]': { '{}': ['a'] } }, + } as unknown as State, + new SchemaRegistry(), + ); + expect(model.tables.map(t => t.kind)).toEqual(['unknown', 'collection']); + }); + + it('records endpoints and schemas from actions', async () => { + const registry = new SchemaRegistry(); + const next = jest.fn(() => Promise.resolve()); + const dispatch = registry.middleware({} as any)(next); + await dispatch({ + type: actionTypes.SET_RESPONSE, + key: 'k', + endpoint: { schema: [Post] }, + args: [1], + } as any); + await dispatch({ type: actionTypes.SET, schema: Comment } as any); + await dispatch({ type: actionTypes.RESET } as any); + expect(next).toHaveBeenCalledTimes(3); + expect(registry.endpoints.get('k')?.args).toEqual([1]); + expect(registry.entities.get('Comment')).toBe(Comment); + registry.learn(null); + registry.cleanup(); + }); + it('finds changed rows', () => { const next = { ...state, @@ -146,6 +202,10 @@ describe('store model', () => { }); it('splits endpoint keys', () => { + expect(splitKey('GET /relative')).toEqual({ + method: 'GET', + path: '/relative', + }); expect(splitKey('GET https://example.com/posts?page=2')).toEqual({ method: 'GET', path: '/posts?page=2', From 657c66ab0af882606e479b4c94dccf5767cb32a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:37:01 +0000 Subject: [PATCH 04/28] test(website): Fix StoreUI test row typing Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SaF7H7PxLeSAoqV55xaXL3 --- .../components/Playground/preview/__tests__/StoreUI.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx index fceae22e054e..5f4ff111a45f 100644 --- a/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx +++ b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx @@ -51,7 +51,7 @@ describe('StoreUI', () => { it('reveal opens everything leading to a row', () => { const rows = Array.from({ length: ROW_LIMIT + 1 }, (_, i) => ({ id: entityId('Late', `${i}`), - })); + })) as unknown as EntityTable['rows']; const m = { endpoints: [], tables: ['A', 'B', 'C'].map(table).concat({ ...table('Late'), rows }), From b4bd2ac6fc15383ef10012146dec2068517e9ba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:41:50 +0000 Subject: [PATCH 05/28] fix(website): Show failed endpoints, pending optimistic updates, Scalar refs and error details in the Store - List endpoints that only have meta (errors, invalidations) - Track the optimistic queue in SchemaRegistry: the state components read has it applied and emptied - Link standalone Scalar cells to their table rows - Keep an Error's name, message and stack Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SaF7H7PxLeSAoqV55xaXL3 --- .../preview/__tests__/storeModel.test.ts | 67 ++++++++++++++++++- .../Playground/preview/store/Sections.tsx | 5 +- .../Playground/preview/store/model.ts | 24 +++++-- .../Playground/preview/store/refs.ts | 18 ++++- .../preview/store/schemaRegistry.ts | 29 ++++++++ 5 files changed, 129 insertions(+), 14 deletions(-) diff --git a/website/src/components/Playground/preview/__tests__/storeModel.test.ts b/website/src/components/Playground/preview/__tests__/storeModel.test.ts index d51b545c5e3e..313bd614431d 100644 --- a/website/src/components/Playground/preview/__tests__/storeModel.test.ts +++ b/website/src/components/Playground/preview/__tests__/storeModel.test.ts @@ -3,8 +3,7 @@ import { Collection, Entity, schema } from '@data-client/endpoint'; import { actionTypes, type State } from '@data-client/react'; import { buildModel, changedIds, entityId, splitKey } from '../store/model'; -import { resolveRow } from '../store/refs'; -import { resolve } from '../store/refs'; +import { plain, resolve, resolveRow } from '../store/refs'; import SchemaRegistry from '../store/schemaRegistry'; class User extends Entity { @@ -96,18 +95,45 @@ describe('store model', () => { expect(resolve('1', new schema.Invalidate(Post))).toEqual(ref('Post', '1')); }); - it('shows Scalar cells as plain values', () => { + it('links standalone Scalar cells and shows entity-field tuples plainly', () => { const scalar = new schema.Scalar({ lens: (args: any) => args[0]?.portfolio, key: 'portfolio', entity: User, }); + expect(resolve(['User|1|a'], [scalar])).toEqual({ + t: 'arr', + items: [{ t: 'ref', key: scalar.key, pk: 'User|1|a' }], + }); expect(resolve(['1', 'pct', 'User'], scalar)).toEqual({ t: 'arr', items: ['1', 'pct', 'User'].map(v => ({ t: 'val', v })), }); }); + it('keeps error details', () => { + const error = Object.assign(new TypeError('boom'), { status: 500 }); + const entries = (plain(error) as any).entries.map(([k]: any) => k); + expect(entries).toEqual(['name', 'message', 'status', 'stack']); + expect((plain(error) as any).entries[1][1]).toEqual({ + t: 'val', + v: 'boom', + }); + }); + + it('lists endpoints that only have meta', () => { + const failed = { + ...state, + meta: { 'GET /broken': { error: new Error('x'), date: 1 } }, + } as unknown as State; + const model = buildModel(failed, new SchemaRegistry()); + expect(model.endpoints.map(e => e.key)).toEqual([ + 'GET https://example.com/posts', + 'GET /broken', + ]); + expect([...changedIds(state, failed)]).toEqual(['e\u001fGET /broken']); + }); + it('falls back to plain values when the stored shape does not match', () => { const val = (v: unknown) => ({ t: 'val', v }); expect(resolve('x', new schema.Values(User))).toEqual(val('x')); @@ -190,6 +216,41 @@ describe('store model', () => { registry.cleanup(); }); + it('tracks optimistic updates until their response arrives', () => { + const registry = new SchemaRegistry(); + const dispatch = registry.middleware({} as any)(() => Promise.resolve()); + const endpoint = { getOptimisticResponse: () => 1, sideEffect: true }; + const fetch = (fetchedAt: number) => + dispatch({ + type: actionTypes.FETCH, + key: 'k', + endpoint, + args: [fetchedAt], + meta: { fetchedAt }, + } as any); + fetch(1); + fetch(2); + dispatch({ + type: actionTypes.FETCH, + key: 'plain', + endpoint: {}, + args: [], + meta: { fetchedAt: 3 }, + } as any); + expect(registry.optimistic.map(o => o.fetchedAt)).toEqual([1, 2]); + dispatch({ + type: actionTypes.SET_RESPONSE, + key: 'k', + endpoint, + args: [1], + meta: { fetchedAt: 1 }, + } as any); + expect(registry.optimistic.map(o => o.fetchedAt)).toEqual([2]); + expect(buildModel(state, registry).optimistic).toBe(registry.optimistic); + dispatch({ type: actionTypes.RESET } as any); + expect(registry.optimistic).toEqual([]); + }); + it('finds changed rows', () => { const next = { ...state, diff --git a/website/src/components/Playground/preview/store/Sections.tsx b/website/src/components/Playground/preview/store/Sections.tsx index 7e809226ae9b..55094a0ea337 100644 --- a/website/src/components/Playground/preview/store/Sections.tsx +++ b/website/src/components/Playground/preview/store/Sections.tsx @@ -223,10 +223,7 @@ function OptimisticRow({
- +
)} diff --git a/website/src/components/Playground/preview/store/model.ts b/website/src/components/Playground/preview/store/model.ts index af9440ed11ea..2bb98f60363e 100644 --- a/website/src/components/Playground/preview/store/model.ts +++ b/website/src/components/Playground/preview/store/model.ts @@ -4,6 +4,7 @@ import type { State } from '@data-client/react'; import { forEachRef, resolve, resolveRow, type VNode } from './refs'; import type { EndpointRecord, + PendingOptimistic, default as SchemaRegistry, } from './schemaRegistry'; @@ -48,7 +49,7 @@ export interface StoreModel { readonly tables: readonly EntityTable[]; /** Row id → everything that references it */ readonly referrers: ReadonlyMap; - readonly optimistic: State['optimistic']; + readonly optimistic: readonly PendingOptimistic[]; readonly indexes: State['indexes']; readonly lastReset: number; } @@ -67,9 +68,14 @@ export function buildModel( else if (list[list.length - 1].id !== from.id) list.push(from); }); - const endpoints = Object.entries(state.endpoints).map(([key, raw]) => { + // errors and invalidations can leave meta without a stored response + const endpointKeys = new Set([ + ...Object.keys(state.endpoints), + ...Object.keys(state.meta), + ]); + const endpoints = [...endpointKeys].map(key => { const record = registry.endpoints.get(key); - const value = resolve(raw, record?.endpoint.schema); + const value = resolve(state.endpoints[key], record?.endpoint.schema); const row: EndpointRow = { id: endpointId(key), key, @@ -108,7 +114,7 @@ export function buildModel( endpoints, tables, referrers, - optimistic: state.optimistic, + optimistic: registry.optimistic, indexes: state.indexes, lastReset: state.lastReset, }; @@ -143,8 +149,14 @@ export function splitKey(key: string) { export function changedIds(prev: State, next: State) { const ids = new Set(); if (prev === next) return ids; - for (const [key, value] of Object.entries(next.endpoints)) { - if (prev.endpoints[key] !== value || prev.meta[key] !== next.meta[key]) + for (const key of new Set([ + ...Object.keys(next.endpoints), + ...Object.keys(next.meta), + ])) { + if ( + prev.endpoints[key] !== next.endpoints[key] || + prev.meta[key] !== next.meta[key] + ) ids.add(endpointId(key)); } for (const [key, rows] of Object.entries(next.entities)) { diff --git a/website/src/components/Playground/preview/store/refs.ts b/website/src/components/Playground/preview/store/refs.ts index 07357f4cbbae..1f8dcbcdcf1a 100644 --- a/website/src/components/Playground/preview/store/refs.ts +++ b/website/src/components/Playground/preview/store/refs.ts @@ -49,7 +49,12 @@ export function resolve(value: unknown, schema: any): VNode { return Array.isArray(value) ? { t: 'arr', items: value.map(v => resolve(v, schema[0])) } : plain(value); - if (schema instanceof s.Scalar) return plain(value); + // Standalone cells are stored by compound pk; entity-field tuples need + // endpoint args to find their cell, so they stay plain + if (schema instanceof s.Scalar) + return typeof value === 'string' ? + { t: 'ref', key: schema.key, pk: value } + : plain(value); if (isEntityLike(schema)) return typeof value === 'string' || typeof value === 'number' ? { t: 'ref', key: schema.key, pk: `${value}` } @@ -112,6 +117,17 @@ export function resolveRow(row: unknown, table: any): VNode { export function plain(value: unknown): VNode { if (Array.isArray(value)) return { t: 'arr', items: value.map(plain) }; + // name, message and stack are not enumerable + if (value instanceof Error) + return { + t: 'obj', + entries: [ + ['name', plain(value.name)], + ['message', plain(value.message)], + ...Object.entries(value).map(([k, v]) => [k, plain(v)] as const), + ['stack', plain(value.stack)], + ], + }; if (value && typeof value === 'object' && !(value instanceof Date)) return { t: 'obj', diff --git a/website/src/components/Playground/preview/store/schemaRegistry.ts b/website/src/components/Playground/preview/store/schemaRegistry.ts index 47f22ceb884e..7bdc47f01ee6 100644 --- a/website/src/components/Playground/preview/store/schemaRegistry.ts +++ b/website/src/components/Playground/preview/store/schemaRegistry.ts @@ -12,6 +12,12 @@ export interface EndpointRecord { readonly args: readonly unknown[]; } +export interface PendingOptimistic { + readonly key: string; + readonly args: readonly unknown[]; + readonly fetchedAt: number; +} + /** Remembers each endpoint (and every entity schema reachable from it) as * actions pass through, since the store only holds keys and ids. * Lives in the preview's DataProvider; extend this manager (rather than @@ -21,6 +27,9 @@ export default class SchemaRegistry implements Manager { readonly endpoints = new Map(); /** Entity table key (`state.entities[key]`) → Entity class, Collection or Scalar */ readonly entities = new Map(); + /** Optimistic updates awaiting their response. The store's own queue is + * already applied (and so emptied) in the state components can read. */ + optimistic: readonly PendingOptimistic[] = []; middleware: Middleware = () => next => action => { switch (action.type) { @@ -32,6 +41,10 @@ export default class SchemaRegistry implements Manager { args: action.args, }); this.learn(action.endpoint.schema); + this.trackOptimistic(action); + break; + case actionTypes.RESET: + this.optimistic = []; break; case actionTypes.SET: this.learn(action.schema); @@ -42,6 +55,22 @@ export default class SchemaRegistry implements Manager { cleanup() {} + /** Mirrors core's fetchReducer and filterOptimistic */ + private trackOptimistic(action: any) { + const { key, args, endpoint, meta } = action; + if (action.type === actionTypes.FETCH) { + if (endpoint.getOptimisticResponse && endpoint.sideEffect) + this.optimistic = [ + ...this.optimistic, + { key, args, fetchedAt: meta.fetchedAt }, + ]; + } else if (action.type === actionTypes.SET_RESPONSE) { + this.optimistic = this.optimistic.filter( + o => o.key !== key || o.fetchedAt !== meta.fetchedAt, + ); + } + } + learn(schema: any) { if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) return; From db882806a58a8266cd88bd3abe4996fdcc7c8ae5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:49:09 +0000 Subject: [PATCH 06/28] fix(website): Stop the Store inspector at cyclic values Denormalized Returns can contain cycles (bidirectional relationships), which overflowed plain(). Repeats on the current path now show as [Circular]. Errors with their own enumerable name or message list it once. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SaF7H7PxLeSAoqV55xaXL3 --- .../preview/__tests__/storeModel.test.ts | 39 ++++++++++++++- .../Playground/preview/store/Value.tsx | 3 +- .../Playground/preview/store/refs.ts | 47 +++++++++++-------- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/website/src/components/Playground/preview/__tests__/storeModel.test.ts b/website/src/components/Playground/preview/__tests__/storeModel.test.ts index 313bd614431d..011218011b76 100644 --- a/website/src/components/Playground/preview/__tests__/storeModel.test.ts +++ b/website/src/components/Playground/preview/__tests__/storeModel.test.ts @@ -3,7 +3,7 @@ import { Collection, Entity, schema } from '@data-client/endpoint'; import { actionTypes, type State } from '@data-client/react'; import { buildModel, changedIds, entityId, splitKey } from '../store/model'; -import { plain, resolve, resolveRow } from '../store/refs'; +import { CIRCULAR, plain, resolve, resolveRow } from '../store/refs'; import SchemaRegistry from '../store/schemaRegistry'; class User extends Entity { @@ -121,6 +121,43 @@ describe('store model', () => { }); }); + it('keeps own error fields once and stops at cycles', () => { + const error = Object.assign(new Error('boom'), { name: 'HttpError' }); + const keys = (plain(error) as any).entries.map(([k]: any) => k); + expect(keys).toEqual(['name', 'message', 'stack']); + const post: any = { id: '1', tags: [] }; + post.author = { id: '2', posts: [post] }; + post.tags.push(post.tags); + const shared = { n: 1 }; + expect(plain({ post, a: shared, b: shared })).toEqual({ + t: 'obj', + entries: [ + [ + 'post', + { + t: 'obj', + entries: [ + ['id', { t: 'val', v: '1' }], + ['tags', { t: 'arr', items: [{ t: 'val', v: CIRCULAR }] }], + [ + 'author', + { + t: 'obj', + entries: [ + ['id', { t: 'val', v: '2' }], + ['posts', { t: 'arr', items: [{ t: 'val', v: CIRCULAR }] }], + ], + }, + ], + ], + }, + ], + ['a', { t: 'obj', entries: [['n', { t: 'val', v: 1 }]] }], + ['b', { t: 'obj', entries: [['n', { t: 'val', v: 1 }]] }], + ], + }); + }); + it('lists endpoints that only have meta', () => { const failed = { ...state, diff --git a/website/src/components/Playground/preview/store/Value.tsx b/website/src/components/Playground/preview/store/Value.tsx index fdaa701a3234..07966383edfe 100644 --- a/website/src/components/Playground/preview/store/Value.tsx +++ b/website/src/components/Playground/preview/store/Value.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import React, { useState } from 'react'; import { entityId } from './model'; -import type { VNode } from './refs'; +import { CIRCULAR, type VNode } from './refs'; import styles from './store.module.css'; import { useStoreUI } from './StoreUI'; @@ -173,6 +173,7 @@ function BlockList({ node }: { node: Extract }) { } function Primitive({ value, name }: { value: unknown; name?: string }) { + if (value === CIRCULAR) return [Circular]; if (value === null || value === undefined) return {String(value)}; if (typeof value === 'string') diff --git a/website/src/components/Playground/preview/store/refs.ts b/website/src/components/Playground/preview/store/refs.ts index 1f8dcbcdcf1a..de819bdddd59 100644 --- a/website/src/components/Playground/preview/store/refs.ts +++ b/website/src/components/Playground/preview/store/refs.ts @@ -115,25 +115,34 @@ export function resolveRow(row: unknown, table: any): VNode { return plain(row); } -export function plain(value: unknown): VNode { - if (Array.isArray(value)) return { t: 'arr', items: value.map(plain) }; - // name, message and stack are not enumerable - if (value instanceof Error) - return { - t: 'obj', - entries: [ - ['name', plain(value.name)], - ['message', plain(value.message)], - ...Object.entries(value).map(([k, v]) => [k, plain(v)] as const), - ['stack', plain(value.stack)], - ], - }; - if (value && typeof value === 'object' && !(value instanceof Date)) - return { - t: 'obj', - entries: Object.entries(value).map(([k, v]) => [k, plain(v)]), - }; - return { t: 'val', v: value }; +/** Stands in for an object that contains itself (denormalized cycles) */ +export const CIRCULAR = Symbol('circular'); + +export function plain(value: unknown, ancestors = new Set()): VNode { + if (!value || typeof value !== 'object' || value instanceof Date) + return { t: 'val', v: value }; + if (ancestors.has(value)) return { t: 'val', v: CIRCULAR }; + ancestors.add(value); + const child = (v: unknown) => plain(v, ancestors); + let node: VNode; + if (Array.isArray(value)) node = { t: 'arr', items: value.map(child) }; + else { + let entries = Object.entries(value); + // an Error's name, message and stack are usually not enumerable; a Map + // keeps each key once, in first-seen order + if (value instanceof Error) + entries = [ + ...new Map([ + ['name', value.name], + ['message', value.message], + ...entries, + ['stack', value.stack], + ]), + ]; + node = { t: 'obj', entries: entries.map(([k, v]) => [k, child(v)]) }; + } + ancestors.delete(value); + return node; } export function forEachRef( From 4fa15a4ebc1b2b8d7ad962b2b41b7c12afed5c78 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:45:06 +0000 Subject: [PATCH 07/28] enhance(website): Dive-in Store inspector with paged tables and records Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SaF7H7PxLeSAoqV55xaXL3 --- .../preview/__tests__/StorePanel.test.tsx | 196 ++++++++ .../preview/__tests__/StoreUI.test.tsx | 1 + .../preview/__tests__/storeModel.test.ts | 18 +- .../Playground/preview/store/Details.tsx | 27 +- .../Playground/preview/store/DiveViews.tsx | 288 +++++++++++ .../Playground/preview/store/RootView.tsx | 265 +++++++++++ .../Playground/preview/store/StorePanel.tsx | 321 +++++++++++-- .../Playground/preview/store/Table.tsx | 388 +++++++++++++++ .../Playground/preview/store/TableView.tsx | 191 -------- .../Playground/preview/store/TreeView.tsx | 13 +- .../Playground/preview/store/Value.tsx | 145 +++++- .../Playground/preview/store/columns.ts | 92 ++++ .../Playground/preview/store/model.ts | 202 ++++++-- .../Playground/preview/store/nav.tsx | 26 + .../Playground/preview/store/store.module.css | 448 +++++++++++++++--- website/src/components/motion/index.ts | 1 + 16 files changed, 2258 insertions(+), 364 deletions(-) create mode 100644 website/src/components/Playground/preview/__tests__/StorePanel.test.tsx create mode 100644 website/src/components/Playground/preview/store/DiveViews.tsx create mode 100644 website/src/components/Playground/preview/store/RootView.tsx create mode 100644 website/src/components/Playground/preview/store/Table.tsx delete mode 100644 website/src/components/Playground/preview/store/TableView.tsx create mode 100644 website/src/components/Playground/preview/store/columns.ts create mode 100644 website/src/components/Playground/preview/store/nav.tsx diff --git a/website/src/components/Playground/preview/__tests__/StorePanel.test.tsx b/website/src/components/Playground/preview/__tests__/StorePanel.test.tsx new file mode 100644 index 000000000000..f915450ef9a1 --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/StorePanel.test.tsx @@ -0,0 +1,196 @@ +/// +import { Collection, Entity } from '@data-client/endpoint'; +import { StateContext, type State } from '@data-client/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import React from 'react'; + +import SchemaRegistry from '../store/schemaRegistry'; +import StorePanel from '../store/StorePanel'; + +jest.mock('../../../../utils/tabStorage', () => ({ + useTabStorage: () => require('react').useState(null), +})); + +class User extends Entity { + id = ''; + name = ''; +} +class Post extends Entity { + id = ''; + title = ''; + body = ''; + summary = ''; + author = User.fromJS(); + tags: string[] = []; + static schema = { author: User }; +} +class Comment extends Entity { + id = ''; + text = ''; + static schema = {}; +} +const comments = new Collection([Comment]); + +const range = (n: number) => Array.from({ length: n }, (_, i) => `${i + 1}`); +const posts = Object.fromEntries( + range(12).map(id => [ + id, + { + id, + title: `Post title number ${id} that is long`, + body: 'A body that wants plenty of room in its column', + summary: 'Another long string field to push columns onto pages', + author: '1', + tags: ['a', 'b'], + }, + ]), +); +const commentIds = range(40); + +const state = { + meta: { + 'GET https://example.com/posts': { date: 0, expiresAt: 0 }, + 'GET https://example.com/broken': { + date: 0, + expiresAt: 0, + error: new Error('404 Not Found'), + }, + }, + entitiesMeta: { + Post: { 1: { date: 1, fetchedAt: 1, expiresAt: 2 } }, + }, + indexes: {}, + optimistic: [], + lastReset: 0, + endpoints: { + 'GET https://example.com/posts': range(12), + 'GET https://example.com/comments': '{}', + }, + entities: { + User: { 1: { id: '1', name: 'Paul' } }, + Post: posts, + Comment: Object.fromEntries( + commentIds.map(id => [id, { id, text: `comment ${id}` }]), + ), + '[Comment]': { '{}': commentIds }, + }, +} as unknown as State; + +function registry() { + const r = new SchemaRegistry(); + r.endpoints.set('GET https://example.com/posts', { + endpoint: { schema: [Post] }, + args: [], + }); + r.endpoints.set('GET https://example.com/comments', { + endpoint: { schema: comments }, + args: [], + }); + r.learn([Post]); + r.learn(comments); + return r; +} + +const top = () => + document.querySelector('[data-level]:not([data-covered])')!; +const crumbs = () => + [...top().querySelectorAll('nav > *')].map(el => el.textContent); +const press = (key: string) => + fireEvent.keyDown(document.activeElement ?? top(), { key }); + +function mount(s = state, r = registry()) { + return render( + + + , + ); +} + +describe('StorePanel table view', () => { + it('shows a few rows of every table, Collections after their Entity', () => { + mount(); + const groups = [...top().querySelectorAll('[data-table]')].map(g => + g.getAttribute('data-table'), + ); + expect(groups).toEqual(['User', 'Post', 'Comment', '[Comment]']); + const post = within(top().querySelector('[data-table=Post]')!); + expect(post.getAllByRole('row').filter(r => r.dataset.id)).toHaveLength(5); + expect(post.getByText('7 more')).toBeTruthy(); + // the error endpoint shows its message + expect(screen.getByText('404 Not Found')).toBeTruthy(); + }); + + it('dives into a record and back', () => { + mount(); + fireEvent.click(within(top()).getByText('"Paul"')); + expect(crumbs()).toEqual(['State', '›', 'User 1']); + expect(within(top()).getByText('used by')).toBeTruthy(); + // a ref chip dives further + fireEvent.click(within(top()).getByRole('button', { name: 'Post 1' })); + expect(crumbs()).toEqual(['State', '›', 'User 1', '›', 'Post 1']); + act(() => press('Escape')); + expect(crumbs()).toEqual(['State', '›', 'User 1']); + fireEvent.click(within(top()).getByLabelText('Back')); + expect(top().querySelector('nav')).toBeNull(); + }); + + it('lists a whole table with a filter', () => { + mount(); + fireEvent.click(screen.getByText('7 more')); + expect(crumbs()).toEqual(['State', '›', 'Post12']); + const rows = () => + [...top().querySelectorAll('tr[data-id]')].map( + r => r.getAttribute('data-id')?.split('\u001f')[2], + ); + expect(rows()).toHaveLength(12); + fireEvent.change(within(top()).getByLabelText('Filter rows'), { + target: { value: 'number 1' }, + }); + expect(rows()).toEqual(['1', '10', '11', '12']); + expect(within(top()).getByText('4 of 12')).toBeTruthy(); + fireEvent.change(within(top()).getByLabelText('Filter rows'), { + target: { value: 'nope' }, + }); + expect(within(top()).getByText('no matches')).toBeTruthy(); + }); + + it('pages fields and shows the rest below a row', () => { + mount(); + const post = () => within(top().querySelector('[data-table=Post]')!); + const headers = () => + post() + .getAllByRole('columnheader') + .map(h => h.textContent); + expect(headers()).toEqual(['id', 'title', 'body', '']); + fireEvent.click(post().getByLabelText('Next fields')); + expect(headers()).toEqual(['id', 'summary', 'author', 'tags', '']); + fireEvent.click(post().getByLabelText('Previous fields')); + + const more = post().getAllByLabelText(/Show \d+ more fields below/)[0]; + fireEvent.click(more); + expect(post().getByText('summary')).toBeTruthy(); + fireEvent.click(post().getByLabelText('Hide fields')); + expect(post().queryByLabelText('Hide fields')).toBeNull(); + fireEvent.keyDown(post().getAllByLabelText(/more fields below/)[0], { + key: 'Enter', + }); + expect(post().getByLabelText('Hide fields')).toBeTruthy(); + }); + + it('dives into long lists of refs and endpoint lists', () => { + mount(); + const collection = within(top().querySelector('[data-table="[Comment]"]')!); + fireEvent.click(collection.getByText(/^\+\d+$/)); + expect(crumbs()[2]).toMatch(/^\[Comment\] all items40$/); + expect(top().querySelectorAll('tr[data-id]').length).toBeGreaterThan(0); + }); + + it('switches to the tree view', () => { + mount(); + fireEvent.click(screen.getByLabelText('Tree view')); + expect( + screen.getByLabelText('Tree view').getAttribute('aria-pressed'), + ).toBe('true'); + expect(document.querySelector('[data-table]')).toBeNull(); + }); +}); diff --git a/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx index 5f4ff111a45f..8844b011a1ce 100644 --- a/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx +++ b/website/src/components/Playground/preview/__tests__/StoreUI.test.tsx @@ -18,6 +18,7 @@ const table = (key: string): EntityTable => ({ kind: 'entity', rows: [], fields: [], + get: () => undefined, }); const model = (...keys: string[]) => ({ endpoints: [], tables: keys.map(table) }) as unknown as StoreModel; diff --git a/website/src/components/Playground/preview/__tests__/storeModel.test.ts b/website/src/components/Playground/preview/__tests__/storeModel.test.ts index 011218011b76..94fa91c8d899 100644 --- a/website/src/components/Playground/preview/__tests__/storeModel.test.ts +++ b/website/src/components/Playground/preview/__tests__/storeModel.test.ts @@ -2,7 +2,13 @@ import { Collection, Entity, schema } from '@data-client/endpoint'; import { actionTypes, type State } from '@data-client/react'; -import { buildModel, changedIds, entityId, splitKey } from '../store/model'; +import { + buildModel, + changedIds, + entityId, + referrersOf, + splitKey, +} from '../store/model'; import { CIRCULAR, plain, resolve, resolveRow } from '../store/refs'; import SchemaRegistry from '../store/schemaRegistry'; @@ -202,15 +208,17 @@ describe('store model', () => { registryFor('GET https://example.com/posts', [Post]), ); const labels = (key: string, pk: string) => - model.referrers.get(entityId(key, pk))?.map(r => r.label); + referrersOf(model) + .get(entityId(key, pk)) + ?.map(r => r.label); expect(labels('Post', '1')).toEqual(['GET /posts']); expect(labels('User', '123')).toEqual(['Comment 249', 'Post 1']); - expect(labels('Comment', '249')).toEqual(['[Comment] {"postId":"1"}']); + expect(labels('Comment', '249')).toEqual(['[Comment] postId: 1']); expect(model.tables.map(t => [t.key, t.kind])).toEqual([ ['User', 'entity'], ['Comment', 'entity'], - ['Post', 'entity'], ['[Comment]', 'collection'], + ['Post', 'entity'], ]); }); @@ -220,7 +228,7 @@ describe('store model', () => { t: 'arr', items: [{ t: 'val', v: '1' }], }); - expect(model.referrers.size).toBe(0); + expect(referrersOf(model).size).toBe(0); }); it('guesses table kinds without a schema', () => { diff --git a/website/src/components/Playground/preview/store/Details.tsx b/website/src/components/Playground/preview/store/Details.tsx index 6085fcbc40f6..ea58b59b1da1 100644 --- a/website/src/components/Playground/preview/store/Details.tsx +++ b/website/src/components/Playground/preview/store/Details.tsx @@ -8,11 +8,19 @@ import { type EntityRow, type Referrer, } from './model'; +import { useNav } from './nav'; import { plain } from './refs'; import type { EndpointRecord } from './schemaRegistry'; import { Segmented } from './Sections'; import styles from './store.module.css'; -import { Block, Field, formatTime, RefChip } from './Value'; +import { + Block, + CountChip, + Field, + formatTime, + RECORD_REFS, + RefChip, +} from './Value'; type Meta = EndpointRow['meta']; @@ -112,16 +120,22 @@ export function EntityDetail({ } function ReferencedBy({ - referrers, + referrers = [], }: { referrers: readonly Referrer[] | undefined; }) { + // the table view dives into long lists instead of listing them all + const nav = useNav(); + const shown = + nav && referrers.length > RECORD_REFS ? + referrers.slice(0, RECORD_REFS) + : referrers; return (
used by - {referrers?.length ? + {referrers.length ? - {referrers.map(r => ( + {shown.map(r => ( ))} + {shown.length < referrers.length && ( + r.id)} label="used by"> + +{referrers.length - shown.length} + + )} : nothing}
diff --git a/website/src/components/Playground/preview/store/DiveViews.tsx b/website/src/components/Playground/preview/store/DiveViews.tsx new file mode 100644 index 000000000000..af83480fe3d3 --- /dev/null +++ b/website/src/components/Playground/preview/store/DiveViews.tsx @@ -0,0 +1,288 @@ +import React, { + useDeferredValue, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { EndpointDetail, EntityDetail } from './Details'; +import { + findRow, + isEndpointId, + prettyPk, + referrersOf, + type EndpointRow, + type EntityRow, + type StoreModel, +} from './model'; +import { useNav, type View } from './nav'; +import styles from './store.module.css'; +import { + endpointColumns, + mixedColumns, + Pager, + RowsTable, + tableColumns, + type AnyRow, + type Column, +} from './Table'; + +type ListViewProps = Extract; +type Header = (tools?: React.ReactNode) => React.ReactNode; + +/** Rows rendered beyond the visible ones, on each side */ +const OVERSCAN = 20; +/** Row height until one is measured */ +const ROW_GUESS = 28; + +/** Every row of a table, or of a list of refs, with a filter. Only rows on + * screen render, so it stays fast at any size */ +export function ListView({ + view, + scroller, + header, +}: { + view: ListViewProps; + scroller: React.RefObject; + header: Header; +}) { + const { model, width, push } = useNav()!; + const [filter, setFilter] = useState(''); + const query = useDeferredValue(filter.trim().toLowerCase()); + const [page, setPage] = useState(0); + const [inline, setInline] = useState(null); + + const table = model.tables.find( + t => t.key === (view.table ?? tableOf(view.ids)), + ); + const rows = useMemo( + () => + view.ids ? + view.ids.flatMap(id => findRow(model, id) ?? []) + : (table?.rows ?? []), + [model, view.ids, table], + ); + const matches = useMemo( + () => (query ? rows.filter(row => searchText(row).includes(query)) : rows), + [rows, query], + ); + + let columns: readonly Column[]; + let more: ((row: AnyRow) => number) | undefined; + let pages: readonly (readonly unknown[])[] = []; + if (table) { + const fit = tableColumns(table, rows as EntityRow[], width, page, true); + columns = fit.columns as unknown as Column[]; + more = fit.more as ((row: AnyRow) => number) | undefined; + pages = fit.pages; + } else if (rows.every(row => isEndpointId(row.id))) { + columns = endpointColumns(width) as Column[]; + } else { + columns = mixedColumns(width); + } + + const openIndex = inline ? matches.findIndex(row => row.id === inline) : -1; + const { start, end, before, after, spacer } = useWindow( + scroller, + matches.length, + openIndex, + ); + // a new filter starts from the top + useLayoutEffect(() => { + if (scroller.current) scroller.current.scrollTop = 0; + }, [scroller, query]); + + return ( + <> + {header( + <> + setFilter(e.target.value)} + placeholder="filter" + aria-label="Filter rows" + /> + + , + )} + + columns={columns} + rows={matches.slice(start, end)} + before={before} + after={after} + beforeRef={spacer} + onOpen={row => push({ kind: 'record', id: row.id })} + more={more} + inline={inline} + onInline={setInline} + record={row => ( + + )} + foot={ + query ? + + {matches.length ? + `${matches.length.toLocaleString()} of ${rows.length.toLocaleString()}` + : 'no matches'} + + : null + } + /> + + ); +} + +/** The one table every id of `ids` is in, if they share one */ +function tableOf(ids: readonly string[] | undefined) { + const [first] = ids ?? []; + if (!first || isEndpointId(first)) return; + const prefix = first.slice(0, first.indexOf('\u001f', 2) + 1); + if (ids!.every(id => id.startsWith(prefix))) return prefix.slice(2, -1); +} + +/** Lowercase text a filter matches against, cached with the stored object */ +const searchCache = new WeakMap(); +function searchText(row: AnyRow) { + if ('key' in row) return row.key.toLowerCase(); + const key = row.raw && typeof row.raw === 'object' ? row.raw : null; + let text = key && searchCache.get(key); + if (text) return text; + text = `${prettyPk(row.pk)} ${JSON.stringify(row.raw)}`.toLowerCase(); + if (key) searchCache.set(key, text); + return text; +} + +/** Which of `count` rows are on screen in `scroller`; an open record + * (below row `openIndex`) adds its measured height */ +function useWindow( + scroller: React.RefObject, + count: number, + openIndex: number, +) { + const spacer = useRef(null); + const rowHeight = useRef(ROW_GUESS); + const extra = useRef(0); + const [[start, end], setRange] = useState([0, 2 * OVERSCAN]); + + // measure after each render: a row's height, and the open record's + useLayoutEffect(() => { + const row = spacer.current?.nextElementSibling as HTMLElement | null; + if (row?.offsetHeight) rowHeight.current = row.offsetHeight; + const record = spacer.current?.parentElement?.querySelector( + `.${styles.recordRow}`, + ); + if (record) extra.current = record.offsetHeight; + }); + + useLayoutEffect(() => { + const el = scroller.current; + if (!el) return; + const update = () => { + const top = spacer.current; + if (!top) return; + const base = + top.getBoundingClientRect().top - + el.getBoundingClientRect().top + + el.scrollTop; + const y = el.scrollTop - base; + const h = rowHeight.current; + let first = y / h; + if (openIndex >= 0 && first > openIndex + 1) + first = (y - extra.current) / h; + const from = Math.min( + Math.max(0, Math.floor(first) - OVERSCAN), + Math.max(0, count - 1), + ); + const to = Math.min( + count, + from + Math.ceil(el.clientHeight / h) + 2 * OVERSCAN, + ); + setRange(prev => + prev[0] === from && prev[1] === to ? prev : [from, to], + ); + }; + update(); + el.addEventListener('scroll', update, { passive: true }); + const observer = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update); + observer?.observe(el); + return () => { + el.removeEventListener('scroll', update); + observer?.disconnect(); + }; + }, [scroller, count, openIndex]); + + const h = rowHeight.current; + return { + start, + end, + before: + start * h + (openIndex >= 0 && openIndex < start ? extra.current : 0), + after: + Math.max(0, count - end) * h + (openIndex >= end ? extra.current : 0), + spacer, + }; +} + +/** One row with everything about it */ +export function RecordView({ id, header }: { id: string; header: Header }) { + const { model } = useNav()!; + const row = findRow(model, id); + return ( + <> + {header()} +
+ {!row ? + No longer in the store + : isEndpointId(id) ? + + : + } +
+ + ); +} + +/** What a breadcrumb shows for a view */ +export function crumbLabel(view: View, model: StoreModel): React.ReactNode { + switch (view.kind) { + case 'root': + return 'State'; + case 'list': { + const count = + view.ids?.length ?? + model.tables.find(t => t.key === view.table)?.rows.length ?? + 0; + return ( + <> + {view.label} + {count.toLocaleString()} + + ); + } + case 'record': { + const row = findRow(model, view.id); + if (!row) return '…'; + if ('key' in row) + return ( + <> + {row.method} {row.path} + + ); + return ( + <> + {row.table}{' '} + {prettyPk(row.pk)} + + ); + } + } +} diff --git a/website/src/components/Playground/preview/store/RootView.tsx b/website/src/components/Playground/preview/store/RootView.tsx new file mode 100644 index 000000000000..3ad52fda0595 --- /dev/null +++ b/website/src/components/Playground/preview/store/RootView.tsx @@ -0,0 +1,265 @@ +import clsx from 'clsx'; +import React, { useState } from 'react'; + +import { EntityDetail } from './Details'; +import { + referrersOf, + splitKey, + type EntityRow, + type EntityTable, + type StoreModel, +} from './model'; +import { useNav } from './nav'; +import { plain } from './refs'; +import styles from './store.module.css'; +import { + endpointColumns, + Pager, + RowsTable, + tableColumns, + type Column, +} from './Table'; +import { Field, Inline, Primitive } from './Value'; + +/** Rows a table shows before "N more"; tables this short show them all */ +const PREVIEW_ROWS = 5; +const SHOW_ALL_UNDER = 8; +/** Tables needed before a jump list of them is worth its line */ +const INDEX_OVER = 5; + +const preview = (rows: readonly T[]) => + rows.length > SHOW_ALL_UNDER ? rows.slice(0, PREVIEW_ROWS) : rows; + +/** The whole store at a glance: a few rows of everything */ +export default function RootView() { + const { model, width, push } = useNav()!; + const [closed, setClosed] = useState>( + () => new Set(['internals']), + ); + const section = (name: string, title: string, count: number | undefined) => ({ + name, + title, + count, + open: !closed.has(name), + onToggle: () => + setClosed(prev => { + const next = new Set(prev); + if (!next.delete(name)) next.add(name); + return next; + }), + }); + const entityCount = model.tables.reduce((n, t) => n + t.rows.length, 0); + const endpoints = preview(model.endpoints); + const hiddenEndpoints = model.endpoints.length - endpoints.length; + return ( + <> + {model.optimistic.length > 0 && ( +
+ +
+ )} +
+ {endpoints.length > 0 && ( + push({ kind: 'record', id: row.id })} + foot={ + hiddenEndpoints > 0 && ( + + ) + } + /> + )} +
+
+ {model.tables.length > INDEX_OVER && } + {model.tables.map(table => ( + + ))} +
+
+
+ + +
+
+ + ); +} + +function Section({ + name, + title, + count, + open, + onToggle, + children, +}: { + name: string; + title: string; + count: number | undefined; + open: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( +
+ + {open && children} +
+ ); +} + +/** Jumps to a table further down */ +function TableIndex({ model }: { model: StoreModel }) { + return ( +
+ {model.tables.map(table => ( + + ))} +
+ ); +} + +/** One table: a few rows, its fields paged to fit (the pager shows on hover, + * or after tapping the header on touch) */ +function Group({ table }: { table: EntityTable }) { + const { model, width, push } = useNav()!; + const [page, setPage] = useState(0); + const [inline, setInline] = useState(null); + const [active, setActive] = useState(false); + const rows = preview(table.rows); + const { columns, pages, more } = tableColumns(table, table.rows, width, page); + const hidden = table.rows.length - rows.length; + return ( +
+
setActive(a => !a)}> + {table.key} + + {table.rows.length.toLocaleString()} + + {table.kind === 'collection' && ( + Collection + )} + + + +
+ + columns={columns} + rows={rows} + onOpen={row => push({ kind: 'record', id: row.id })} + more={more} + inline={inline} + onInline={setInline} + record={row => ( + + )} + foot={ + hidden > 0 && ( + + ) + } + /> +
+ ); +} + +type Optimistic = StoreModel['optimistic'][number] & { id: string }; + +/** Updates applied ahead of their response */ +function OptimisticTable({ model }: { model: StoreModel }) { + const columns: Column[] = [ + { + id: 'key', + header: 'key', + width: '40%', + cell: ({ key }) => { + const { method, path } = splitKey(key); + return ( + + + {method} + {' '} + {path} + + ); + }, + }, + { + id: 'args', + header: 'args', + cell: ({ args }) => , + }, + { + id: 'fetchedAt', + header: 'sent', + width: 124, + cell: ({ fetchedAt }) => , + }, + ]; + return ( + ({ ...o, id: `o\u001f${i}` }))} + /> + ); +} diff --git a/website/src/components/Playground/preview/store/StorePanel.tsx b/website/src/components/Playground/preview/store/StorePanel.tsx index 91c18e30ba92..612fa8c8b82b 100644 --- a/website/src/components/Playground/preview/store/StorePanel.tsx +++ b/website/src/components/Playground/preview/store/StorePanel.tsx @@ -1,70 +1,295 @@ import { StateContext, type State } from '@data-client/react'; -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; +import React, { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; -import { buildModel, changedIds } from './model'; +import { crumbLabel, ListView, RecordView } from './DiveViews'; +import { buildModel, changedIds, type StoreModel } from './model'; +import { NavContext, type Nav, type View } from './nav'; +import RootView from './RootView'; import type SchemaRegistry from './schemaRegistry'; -import { Segmented } from './Sections'; import styles from './store.module.css'; import { StoreUIProvider } from './StoreUI'; -import TableView from './TableView'; import TreeView from './TreeView'; import { useTabStorage } from '../../../../utils/tabStorage'; +import { springEasing, springs } from '../../../motion'; -type View = 'table' | 'tree'; -/** Rough width of one table column, used to decide how many fit */ -const COLUMN_WIDTH = 120; +interface Entry { + readonly key: number; + readonly view: View; +} +/** Breadcrumbs shown before the middle ones collapse to `…` */ +const CRUMBS = 4; export default function StorePanel({ registry }: { registry: SchemaRegistry }) { const state = useContext(StateContext); const model = useMemo(() => buildModel(state, registry), [state, registry]); const [stored, setView] = useTabStorage('playgroundStoreView'); - const view: View = stored === 'tree' ? 'tree' : 'table'; + const tree = stored === 'tree'; - const scroller = useRef(null); - const maxColumns = useColumnCount(scroller); - useFlashChanges(scroller, state); + const panel = useRef(null); + const width = useWidth(panel); + useFlashChanges(panel, state); + + return ( +
+
+ + State + + + Actions later + + + + + +
+ {tree ? + + : } +
+ ); +} + +/** The table view: a stack of full-panel levels. Covered levels stay + * mounted (hidden), so going back keeps their scroll, pages and filters */ +function Levels({ model, width }: { model: StoreModel; width: number }) { + const [stack, setStack] = useState([ + { key: 0, view: { kind: 'root' } }, + ]); + const nextKey = useRef(1); + const push = useCallback((view: View) => { + const key = nextKey.current++; + setStack(prev => [...prev, { key, view }]); + }, []); + const nav = useMemo