Compare commits

...

11 Commits

Author SHA1 Message Date
Charles Bochet 3311ca1a58 Add benchmark screenshots and key findings to performance plan
Include cell render and state access benchmark result screenshots
with analysis of key findings: Jotai atoms are the dominant cost
(+312% for 10 reads/cell), derived atoms are 3x cheaper than
individual reads, component depth adds +82-109%, and styling
engines are comparable.

Made-with: Cursor
2026-02-28 13:24:07 +01:00
Charles Bochet 718a57172a Scale up benchmarks: 2000 cells, heavier atoms and state access tests
- Scale from 400 cells (50x8) to 2000 cells (200x10) to amplify
  overhead differences that were lost in noise at smaller scales
- Heavy derived atom now reads 12 sources (was 4): record data,
  selected, focused, active, dragging, pending, hovered, readOnly,
  forbidden, inputOnly, objectMetadataItems, hoverPosition
- Add H2 test: 12 individual atom reads per cell (vs 1 derived)
- Cell render test 13 now reads 10 atoms (was 3), test 16 reads 10
- Add objectMetadataItems global atom (30 objects x 20 fields each)
  to simulate the real large metadata reads
- Clarify UI: "Avg/cell" label, explicit total cell count in header

Made-with: Cursor
2026-02-28 13:18:00 +01:00
Charles Bochet cb128c4158 fix: separate Linaria/Emotion to prevent wyw from mangling Emotion styled
The wyw-in-js (Linaria) plugin was processing ALL files in __perf__/,
including those with Emotion styled components. wyw incorrectly
transforms emotionStyled.div tagged templates, producing null at
runtime. withTheme(null) then crashes accessing .name.

Fix: extract Linaria styled components to perf-linaria-cells.tsx (only
file in wyw include), keep Emotion styled in the main audit file (not
wyw-processed). Also wrap RecordTablePerfPage in ThemeProvider +
ThemeContextProvider for Emotion theme interpolation tests.

Made-with: Cursor
2026-02-28 13:08:50 +01:00
Charles Bochet 0288545d7b Upgrade perf benchmarks: Linaria support, 17+12 tests, visual results
- Add **/__perf__/**/*.tsx to wyw-in-js vite plugin include list,
  enabling Linaria styled components in benchmark files
- Rewrite cell render benchmark with 17 incremental tests:
  inline styles vs Linaria (static/dynamic) vs Emotion (static/dynamic/
  theme/withTheme), context reads (3/5), useState (1/2), event handlers,
  Jotai atoms, 4-level styled nesting, full simulation, 14 wrappers
- Rewrite state access benchmark with 12 tests:
  props vs context (1/2), pre-resolved ctx, Jotai selectors (1/3/5),
  derived atoms, ctx+atoms, unstable refs, memo, nested providers
- Each test runs 5 iterations with min/avg/max stats and bar chart
- Auto-run-all mode with sequential execution and progress indicator
- Render grid offscreen to isolate measurement from layout shift

Made-with: Cursor
2026-02-28 13:00:43 +01:00
Charles Bochet 996947a3cb Fix 2026-02-28 12:48:05 +01:00
Charles Bochet 05917e23a9 fix: move perf route outside AppRouterProviders to bypass auth
The /__perf__/table route was nested inside AppRouterProviders which
wraps all children in AuthProvider and MetadataGater, causing
unauthenticated redirects. Now it's a sibling route outside the
auth-wrapped tree.

Made-with: Cursor
2026-02-28 12:29:57 +01:00
Charles Bochet 3f0cfe66d1 Move perf route to BlankLayout to avoid auth redirect
Made-with: Cursor
2026-02-28 12:28:03 +01:00
Charles Bochet 30dc70a943 Fix POC benchmarks: replace Linaria styled with inline styles
Linaria requires build-time processing and crashes at runtime.
Use plain inline styles for benchmark components since they are
throwaway perf tooling.

Made-with: Cursor
2026-02-28 12:26:22 +01:00
Charles Bochet 7221b45ff7 Delegate onMouseMove from per-cell to table level
Replace 400 individual cell onMouseMove handlers with a single delegated
handler on RecordTableContent. Uses DOM id parsing to resolve cell position,
with ref-based deduplication to skip redundant updates when mouse stays
in the same cell. Also removes useRecordTableBodyContextOrThrow from
RecordTableCellBaseContainer.

Made-with: Cursor
2026-02-28 12:22:25 +01:00
Charles Bochet 49d05f3ca7 Optimize RecordTable cell display path
- Replace FieldFocusContextProvider (useState per cell) with static
  providers: unfocused for display, focused for hover portal. Eliminates
  400 useState instances on a typical table render.
- Hoist useObjectMetadataItems from per-cell RecordTableCellFieldContextGeneric
  to table-level RecordTableContext, eliminating per-cell atom reads.
- Remove useFieldFocus and onMouseLeave handler from RecordTableCellBaseContainer,
  as focus state is now managed by static context providers.

Made-with: Cursor
2026-02-28 12:20:28 +01:00
Charles Bochet 0e44e07fcf Add RecordTable performance audit tooling and improvement plan
Adds profiling instrumentation, POC benchmarks, and a detailed
performance plan for iteratively simplifying the RecordTable cell
render path (currently 31 components deep, 14 per cell).

Made-with: Cursor
2026-02-28 12:13:29 +01:00
24 changed files with 2682 additions and 39 deletions
@@ -17,6 +17,7 @@ import { Authorize } from '~/pages/auth/Authorize';
import { PasswordReset } from '~/pages/auth/PasswordReset';
import { SignInUp } from '~/pages/auth/SignInUp';
import { NotFound } from '~/pages/not-found/NotFound';
import { RecordTablePerfPage } from '~/pages/__perf__/RecordTablePerfPage';
import { RecordIndexPage } from '~/pages/object-record/RecordIndexPage';
import { RecordShowPage } from '~/pages/object-record/RecordShowPage';
import { BookCall } from '~/pages/onboarding/BookCall';
@@ -34,12 +35,14 @@ export const useCreateAppRouter = (
) =>
createBrowserRouter(
createRoutesFromElements(
<Route
element={<AppRouterProviders />}
// To switch state to `loading` temporarily to enable us
// to set scroll position before the page is rendered
loader={async () => Promise.resolve(null)}
>
<>
<Route path="/__perf__/table" element={<RecordTablePerfPage />} />
<Route
element={<AppRouterProviders />}
// To switch state to `loading` temporarily to enable us
// to set scroll position before the page is rendered
loader={async () => Promise.resolve(null)}
>
<Route element={<DefaultLayout />}>
<Route path={AppPath.Verify} element={<VerifyLoginTokenEffect />} />
<Route path={AppPath.VerifyEmail} element={<VerifyEmailEffect />} />
@@ -77,6 +80,7 @@ export const useCreateAppRouter = (
<Route element={<BlankLayout />}>
<Route path={AppPath.Authorize} element={<Authorize />} />
</Route>
</Route>,
</Route>
</>,
),
);
@@ -2,6 +2,19 @@ import { useState } from 'react';
import { FieldFocusContext } from '@/object-record/record-field/ui/contexts/FieldFocusContext';
const NOOP = () => {};
const STATIC_UNFOCUSED_VALUE = {
isFocused: false,
setIsFocused: NOOP,
};
const STATIC_FOCUSED_VALUE = {
isFocused: true,
setIsFocused: NOOP,
};
// Stateful provider for contexts that need dynamic focus (inline cells, etc.)
export const FieldFocusContextProvider = ({ children }: any) => {
const [isFocused, setIsFocused] = useState(false);
@@ -16,3 +29,25 @@ export const FieldFocusContextProvider = ({ children }: any) => {
</FieldFocusContext.Provider>
);
};
// Static provider: no useState, always unfocused. Used in table display path.
export const FieldFocusStaticUnfocusedProvider = ({
children,
}: {
children: React.ReactNode;
}) => (
<FieldFocusContext.Provider value={STATIC_UNFOCUSED_VALUE}>
{children}
</FieldFocusContext.Provider>
);
// Static provider: no useState, always focused. Used in table hover portal.
export const FieldFocusStaticFocusedProvider = ({
children,
}: {
children: React.ReactNode;
}) => (
<FieldFocusContext.Provider value={STATIC_FOCUSED_VALUE}>
{children}
</FieldFocusContext.Provider>
);
@@ -0,0 +1,266 @@
# RecordTable Performance Improvement Plan
## Current State (Audit Findings)
### Component Depth: 31 components from table root to displayed text
For a table with 50 visible rows × 8 columns = **400 cells**, that's **12,400+ component instances**.
**Per-cell render path (14 components from CellWrapper to text):**
```
RecordTableCellWrapper → context + useMemo
RecordTableCellFieldContextWrapper → 2 context reads + instance ID compute
RecordTableCellFieldContextGeneric → 5 hooks (3 contexts, useObjectMetadataItems, permissions)
RecordTableCellStyleWrapper → useContext(ThemeContext) + styled div
RecordTableCell → pass-through
FieldFocusContextProvider → useState PER CELL (!)
RecordTableCellContainer → pass-through
RecordTableCellBaseContainer → 6 hooks (3 contexts, focus, openCell, theme)
RecordTableCellDisplayMode → 4 hooks (context, body ctx, openCell, inputOnly)
RecordTableCellDisplayContainer → styled divs
FieldDisplay → useContext + field type branching
TextFieldDisplay → useRecordFieldValue (Jotai atom)
TextDisplay → styled div
OverflowingTextWithTooltip → 2× useState
```
### Key Issues Found
| Issue | Impact | Details |
|-------|--------|---------|
| `FieldFocusContextProvider` creates `useState` per cell | HIGH | 400 state instances, only used on hover |
| `RecordTableCellFieldContextGeneric` calls `useObjectMetadataItems()` per cell | HIGH | Reads full global metadata list 400× |
| `useOpenRecordTableCellFromCell` called in 2 components per cell | HIGH | Redundant 3-context reads × 2 |
| No `React.memo` anywhere in cell path | HIGH | Every parent re-render cascades unconditionally |
| `RecordTableTr` subscribes to NEXT row's state | MEDIUM | 3 extra atom reads per row for border styling |
| Display + interaction logic mixed in every cell | MEDIUM | Event handlers, closures created for ALL cells |
| 4 styled wrapper divs per cell | MEDIUM | DOM depth + class computation overhead |
| Emotion used for `RecordTableRowDiv` (runtime CSS-in-JS) | MEDIUM | Dynamic theme interpolation per row |
| `FieldContext.Provider` creates new object every render | MEDIUM | Forces all children to re-render |
| `Draggable` wrapper per row | LOWER | DnD overhead even when not dragging |
| `OverflowingTextWithTooltip` has 2× useState | LOWER | Tooltip state per text cell |
## Changes Made
### Tooling & Infrastructure
- **Render profiler** (`useRecordTableRenderProfiler.ts`): Custom hook that logs per-component render counts, total/avg/max render times. Enable via `window.__RECORD_TABLE_PROFILE = true`. Instrumented on `RecordTableTr`, `RecordTableFieldsCells`, `RecordTableCellFieldContextGeneric`, `RecordTableCellBaseContainer`, `RecordTableCellDisplayMode`.
- **Cell render benchmark** (`RecordTableCellPerformanceAudit.tsx`): Renders 400 cells at various complexity levels (minimal div, styled div, with context, with hooks, full replica) and measures mount time. Uses inline styles since Linaria requires build-time processing.
- **State access benchmark** (`RecordTableStateAccessAudit.tsx`): Compares React context reads vs Jotai atom reads vs pre-resolved values across 400 cells.
- **Perf page** (`RecordTablePerfPage.tsx`): Standalone page at `/__perf__/table`, routed outside `AppRouterProviders` to bypass auth/metadata providers entirely.
- **Perf overlay** (`RecordTablePerfOverlay.tsx`): Mountable inside any authenticated page. Toggle with `Ctrl+Shift+P` or `window.__SHOW_TABLE_PERF = true`.
### Phase 1 Progress (Display/Interaction Split)
**1a. Removed `FieldFocusContextProvider` from display path** (DONE)
- `RecordTableCell` now wraps cells in `FieldFocusStaticUnfocusedProvider` (a static context with `isFocused: false`, no `useState`).
- `RecordTableCellHoveredPortalContent` wraps hovered cell content in `FieldFocusStaticFocusedProvider` (static `isFocused: true`).
- Eliminates 400 `useState` instances from the display path.
**1b. Event delegation for onMouseMove** (DONE)
- Removed `onMouseMove` and `onMouseLeave` from each `RecordTableCellBaseContainer`.
- Added a single delegated `onMouseMove` on `RecordTableContent` that parses cell position from `id="record-table-cell-{col}-{row}"` via `event.target.closest`.
- Uses `lastHoverPositionRef` to skip redundant updates when mouse stays in the same cell.
- Eliminates 400 closure creations for mouse handlers.
**1b (continued). Removed unused hooks from `RecordTableCellBaseContainer`** (DONE)
- Removed `useFieldFocus` (no longer needed since focus is static context).
- Removed `useRecordTableBodyContextOrThrow` (mouse handlers were the only consumer).
- Removed `handleContainerMouseMove` and `onMouseLeave` handlers.
### Phase 3 Progress (State Optimization)
**3a. Hoisted `useObjectMetadataItems()` out of cell loop** (DONE)
- Added `objectMetadataItems` to `RecordTableContext` type and `RecordTableContextProvider`.
- `RecordTableCellFieldContextGeneric` now reads `objectMetadataItems` from the table context instead of calling `useObjectMetadataItems()` per cell.
- Eliminates 400 per-cell reads of the global metadata atom.
- Updated `RecordTableDecorator.tsx` and `RecordTableCell.perf.stories.tsx` to satisfy the new context type.
## Improvement Plan
### Phase 1: Separate Display from Interaction (HIGH IMPACT)
**Goal:** The display path should ONLY display. Interaction (hover, focus, edit) is handled by portals and event delegation.
**1a. Remove `FieldFocusContextProvider` from display path**
- Replaced with static context providers. Eliminates 400 `useState` instances.
**1b. Make `RecordTableCellBaseContainer` display-only** ✅ (partial)
- `onMouseMove` and `onMouseLeave` removed, delegated to table level. ✅
- `onClick` handler still present — needs event delegation or portal handling. TODO.
**1c. Make `RecordTableCellDisplayMode` display-only**
- Remove `useOpenRecordTableCellFromCell` and `useIsFieldInputOnly`.
- Click-to-edit handled by event delegation or portals.
- Eliminates 4 hook calls per cell.
**1d. Lazy tooltip in `OverflowingTextWithTooltip`**
- Replace per-cell `useState` with CSS `:hover` + a single shared tooltip portal.
- Eliminates 400 × 2 `useState` instances.
### Phase 2: Flatten Component Hierarchy
**Goal:** Reduce from 14 components per cell to ~5-6.
**2a. Merge cell container chain**
Merge `RecordTableCellContainer` + `RecordTableCellBaseContainer` + `RecordTableCellDisplayMode` + `RecordTableCellDisplayContainer` into a single `RecordTableCellDisplay` component.
- Currently 4 components → 1.
**2b. Merge cell context chain**
Merge `RecordTableCellWrapper` + `RecordTableCellFieldContextWrapper` + `RecordTableCellFieldContextGeneric` into a single `RecordTableCellContextSetup` that provides all cell-level context.
- Currently 3 components → 1.
**2c. Remove `RecordTableCell` as pass-through**
After Phase 1 removes `FieldFocusContextProvider`, `RecordTableCell` just renders `RecordTableCellContainer`. Inline it.
**Target structure per cell:**
```
RecordTableCellContextSetup → all contexts in one place
RecordTableCellDisplay → single styled div, display-only
FieldDisplay → field type branching
TextFieldDisplay → data read + render
```
### Phase 3: Optimize State Access
**Goal:** Reduce per-cell computation, eliminate unnecessary atom reads.
**3a. Hoist `useObjectMetadataItems()` out of cell loop**
- Moved to `RecordTableContextProvider`, read from table context in cells.
**3b. Pre-compute `fieldDefinition` and permissions at table level**
- `fieldDefinitionByFieldMetadataItemId[recordField.fieldMetadataItemId]` lookups are stable per render.
- Permission computation (including junction config) should be done once per field, not per cell.
- Compute a `Map<fieldMetadataItemId, { fieldDefinition, isReadOnly, isForbidden }>` at the `RecordTableFieldsCells` level.
**3c. Reduce `RecordTableTr` atom subscriptions**
- Stop subscribing to next-row state (`focusIndex + 1`). Use CSS adjacent sibling selectors for border styling.
- Eliminates 3 atom reads per row (150 total for 50 rows).
**3d. Move `isRecordReadOnly` out of `RecordTableTr`**
- If permissions are per-object, compute once at table level.
### Phase 4: CSS-only Interactions
**Goal:** Move hover/focus/active styling entirely to CSS, removing React state from the hot path.
**4a. Replace hover portals with CSS `:hover`**
- The `recordTableHoverPositionComponentState` → portal approach can be replaced with CSS on the cell div.
- The portal overlay (buttons, etc.) can be triggered by CSS `:hover` on the row/cell.
**4b. Convert `RecordTableRowDiv` from Emotion to Linaria or plain CSS**
- The `data-focused` / `data-active` data attribute pattern is already in place.
- Extend for all interactive states, removing runtime CSS-in-JS.
**4c. Replace theme prop passing with CSS custom properties**
- Set `--cell-bg`, `--cell-border`, `--cell-color` at the table root via ThemeContext.
- Reference them in static CSS instead of reading theme per component and passing as props.
- Eliminates per-cell theme context reads and dynamic styled-component props.
### Phase 5: Structural Improvements
**5a. Event delegation at table body level**
- Single `onMouseMove`, `onClick`, `onContextMenu` handler on table body.
- Dispatch based on `data-cell-position` and `data-record-id` attributes.
- Eliminates 1200+ event handler registrations.
**5b. Lazy `Draggable` wrapper**
- Only wrap rows with `@hello-pangea/dnd` `Draggable` when drag mode is active.
- Or switch to a lighter drag implementation that doesn't wrap every row.
**5c. Stabilize context values**
- Ensure `FieldContext.Provider`, `RecordTableRowContextProvider` values are stable references.
- React Compiler should help here, but explicit stable references via extraction are safer.
## Measurement Tools
### Render Profiler
Already instrumented on key components. Enable in browser DevTools:
```js
window.__RECORD_TABLE_PROFILE = true
```
Results batch-log every 2s showing per-component render counts, total/avg/max times.
### POC Benchmarks
Available at `/__perf__/table` (outside auth — no login required). 2,000 cells (200 rows x 10 cols), 3 iterations each, min/avg/max stats with visual bar chart.
**Cell Render Benchmark** — 17 tests isolating each cost factor:
![Cell Render Benchmark Results](./cell-render-benchmark.png)
Key findings (2,000 cells):
- Styling engines (0107): Linaria and Emotion are comparable (~4045ms). Emotion theme interpolation adds ~12% overhead.
- Context reads (0809) and useState (1011) are nearly free compared to baseline.
- **Jotai atoms are the dominant cost**: 10 atom reads/cell = +312% (44ms → 181ms). This is the single biggest bottleneck.
- 4-level Linaria nesting (14) adds +82% — component depth is expensive.
- Full simulation with atoms (16) = **253ms (+476%)**, 0.127ms/cell.
- 14 fragment wrappers (17) = +109% — pure component depth without any styled wrappers.
| # | Test | What it measures |
|---|------|-----------------|
| 01 | Inline style (baseline) | Absolute minimum: plain div + style object |
| 02 | Linaria static | Compile-time CSS, no dynamic props |
| 03 | Linaria + 4 dynamic props | Compile-time CSS with prop-based values (real cell pattern) |
| 04 | Emotion static | Runtime CSS-in-JS, no dynamic props |
| 05 | Emotion + 4 dynamic props | Runtime CSS-in-JS with prop interpolation |
| 06 | Emotion + theme interpolation | Runtime CSS with auto-injected theme context |
| 07 | Emotion + withTheme HOC | Runtime CSS with withTheme wrapper (used in codebase) |
| 08 | Linaria dynamic + 3 ctx reads | Styling + context overhead (RowCtx, CellCtx, FieldCtx) |
| 09 | Linaria dynamic + 5 ctx reads | + TableCtx and FocusCtx |
| 10 | + 1 useState per cell | Simulating FieldFocusContextProvider |
| 11 | + 2 useState per cell | + OverflowingTextWithTooltip |
| 12 | + 3 event handlers/cell | Closure creation for onMouseMove, onMouseLeave, onClick |
| 13 | + 10 Jotai atom reads/cell | fieldValue + selected + hovered + focused + active + dragging + pending + readOnly + forbidden + objectMetadata |
| 14 | 4-level Linaria nesting | StyleWrapper > BaseContainer > DisplayOuter > DisplayInner |
| 15 | Full sim (providers + styled + hooks + handlers) | 3 context providers + 4 styled wrappers + 2 useState + 3 handlers |
| 16 | Full sim + 10 Jotai atoms | Closest to real RecordTable cell |
| 17 | 14 fragment wrappers | Pure component depth cost (no styling/state) |
**State Access Benchmark** — 13 tests comparing state access patterns:
![State Access Benchmark Results](./state-access-benchmark.png)
Key findings (2,000 cells):
- Context reads are cheap: 1 ctx = +6%, 2 ctx = +21%. Pre-resolved context is **faster than baseline** (22%).
- **Jotai atoms scale linearly**: 1 atom = +32%, 3 atoms = +95%, 8 atoms = +175%.
- 1 derived atom (12 sources) = +93% vs 12 individual reads = **+294%**. Derived atoms are significantly cheaper when combining many sources.
- Unstable context values (new object per render) are surprisingly cheap on initial mount (7%), but would be devastating on re-renders.
- Row/cell provider hierarchy + atom = +54%.
| # | Test | What it measures |
|---|------|-----------------|
| A | Props only (baseline) | No state reads |
| B | 1 context read | Single useContext |
| C | 2 context reads | Row + Cell contexts |
| D | Pre-resolved context | Optimal: parent pushes final value via context |
| E | 1 Jotai selector/cell | Derived atom from atomFamily |
| F | 3 Jotai atoms/cell | value + selected + hovered (real pattern) |
| G | 8 Jotai atoms/cell | + focused + active + dragging + pending + readOnly |
| H | 1 derived atom (12 sources) | Single atom reading 12 source atoms |
| H2 | 12 individual atom reads/cell | Same 12 sources but read individually per cell |
| I | 2 ctx + 2 atoms | Real pattern: context for position, atoms for data |
| J | Unstable ctx value per cell | New object reference per render (forces children to re-render) |
| K | Stable ctx + React.memo child | Same as J but with memo |
| L | Row provider > cell provider > atom | Real hierarchy: nested providers + atom read |
### Linaria Configuration
The `@wyw-in-js/vite` plugin requires an explicit `include` whitelist in `vite.config.ts`. Linaria styled components live in `perf-linaria-cells.tsx` (included via `'**/perf-linaria-cells.tsx'`). Emotion and Linaria must NOT be mixed in the same wyw-processed file — wyw incorrectly transforms Emotion tagged templates. The dev server must be restarted after changing the vite config.
## Expected Impact
| Phase | Effort | Impact on Initial Render | Impact on Interactions |
|-------|--------|-------------------------|----------------------|
| Phase 1: Display/Interaction split | 3-5 days | ~40-50% fewer hooks/cell | Major: hover doesn't touch React |
| Phase 2: Flatten hierarchy | 3-5 days | ~60% fewer components/cell | Faster re-renders |
| Phase 3: State optimization | 2-3 days | Faster cell init | Fewer atom subscriptions |
| Phase 4: CSS-only interactions | 3-5 days | Slight (fewer atoms) | Major: CSS-only hover/focus |
| Phase 5: Structural | 5-7 days | Fewer event handlers | Lighter DnD, delegation |
## Recommended Execution Order
1. **Phase 1** (highest ROI) → separate display from interaction
2. **Phase 3a/3b** (quick, safe) → hoist computation out of cell loop
3. **Phase 2** (compounds with Phase 1) → flatten hierarchy
4. **Phase 4** → CSS-only interactions
5. **Phase 5** → structural improvements
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { RecordTableCellPerformanceAudit } from './RecordTableCellPerformanceAudit';
import { RecordTableStateAccessAudit } from './RecordTableStateAccessAudit';
// Dev-only overlay to run perf benchmarks on the actual app.
// Open DevTools console and run: window.__SHOW_TABLE_PERF = true
// Or press Ctrl+Shift+P to toggle.
declare global {
interface Window {
__SHOW_TABLE_PERF?: boolean;
}
}
export const RecordTablePerfOverlay = () => {
const [visible, setVisible] = useState(false);
const [tab, setTab] = useState<'cell' | 'state'>('cell');
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.shiftKey && event.key === 'P') {
setVisible((prev) => !prev);
}
};
const interval = setInterval(() => {
if (window.__SHOW_TABLE_PERF && !visible) {
setVisible(true);
}
}, 500);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
clearInterval(interval);
};
}, [visible]);
if (!visible) return null;
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 99999,
background: 'white',
overflow: 'auto',
padding: 16,
}}
>
<div
style={{
display: 'flex',
gap: 8,
marginBottom: 16,
alignItems: 'center',
}}
>
<button
onClick={() => setTab('cell')}
style={{ fontWeight: tab === 'cell' ? 'bold' : 'normal' }}
>
Cell Render Benchmark
</button>
<button
onClick={() => setTab('state')}
style={{ fontWeight: tab === 'state' ? 'bold' : 'normal' }}
>
State Access Benchmark
</button>
<button
onClick={() => {
setVisible(false);
window.__SHOW_TABLE_PERF = false;
}}
style={{ marginLeft: 'auto' }}
>
Close (Ctrl+Shift+P)
</button>
</div>
{tab === 'cell' ? (
<RecordTableCellPerformanceAudit />
) : (
<RecordTableStateAccessAudit />
)}
</div>
);
};
@@ -0,0 +1,831 @@
import { atom, createStore, Provider, useAtomValue } from 'jotai';
import { atomFamily } from 'jotai/utils';
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { LinariaStaticCell as StyledCell } from './perf-linaria-cells';
const ROWS = 200;
const COLS = 10;
const TOTAL_CELLS = ROWS * COLS;
const ITERATIONS = 3;
const gridCss = {
display: 'grid',
gridTemplateColumns: `repeat(${COLS}, 120px)`,
gap: 0,
} as const;
// ---------------------------------------------------------------------------
// Jotai store and atoms
// ---------------------------------------------------------------------------
const perfStore = createStore();
const recordAtomFamily = atomFamily((recordId: string) =>
atom<Record<string, string>>({
name: `Name ${recordId}`,
email: `${recordId}@example.com`,
phone: '555-0100',
company: 'Acme Inc',
city: 'San Francisco',
role: 'Engineer',
status: 'Active',
score: '95',
department: 'Engineering',
manager: 'Jane Doe',
}),
);
const fieldValueSelectorFamily = atomFamily(
({ recordId, fieldName }: { recordId: string; fieldName: string }) =>
atom((get) => get(recordAtomFamily(recordId))?.[fieldName] ?? ''),
(a, b) => a.recordId === b.recordId && a.fieldName === b.fieldName,
);
const rowSelectedFamily = atomFamily((_recordId: string) => atom(false));
const hoverPositionAtom = atom<{ row: number; col: number } | null>(null);
const cellIsHoveredFamily = atomFamily(
({ row, col }: { row: number; col: number }) =>
atom((get) => {
const pos = get(hoverPositionAtom);
return pos !== null && pos.row === row && pos.col === col;
}),
(a, b) => a.row === b.row && a.col === b.col,
);
const rowFocusedFamily = atomFamily((_recordId: string) => atom(false));
const rowActiveFamily = atomFamily((_recordId: string) => atom(false));
const rowDraggingFamily = atomFamily((_recordId: string) => atom(false));
const rowPendingFamily = atomFamily((_recordId: string) => atom(false));
// Per-cell permission/metadata atoms (simulating real RecordTable patterns)
const cellReadOnlyFamily = atomFamily(
(_key: string) => atom(false),
(a, b) => a === b,
);
const cellForbiddenFamily = atomFamily(
(_key: string) => atom(false),
(a, b) => a === b,
);
const cellInputOnlyFamily = atomFamily(
(_key: string) => atom(false),
(a, b) => a === b,
);
// Simulating objectMetadataItems global atom (large object read per cell in real code)
const objectMetadataItemsAtom = atom(
Array.from({ length: 30 }, (_, i) => ({
id: `obj-${i}`,
nameSingular: `Object${i}`,
namePlural: `Object${i}s`,
fields: Array.from({ length: 20 }, (_, j) => ({
id: `field-${i}-${j}`,
name: `field${j}`,
type: 'TEXT',
})),
})),
);
// Heavy derived atom: reads 12 source atoms (simulating real-world derived state)
const cellHeavyComputedFamily = atomFamily(
({
recordId,
fieldName,
row,
col,
}: {
recordId: string;
fieldName: string;
row: number;
col: number;
}) =>
atom((get) => {
const value = get(recordAtomFamily(recordId))?.[fieldName] ?? '';
const isSelected = get(rowSelectedFamily(recordId));
const isFocused = get(rowFocusedFamily(recordId));
const isActive = get(rowActiveFamily(recordId));
const isDragging = get(rowDraggingFamily(recordId));
const isPending = get(rowPendingFamily(recordId));
const isHovered = get(cellIsHoveredFamily({ row, col }));
const isReadOnly = get(cellReadOnlyFamily(`${recordId}-${fieldName}`));
const isForbidden = get(cellForbiddenFamily(`${recordId}-${fieldName}`));
const isInputOnly = get(cellInputOnlyFamily(`${recordId}-${fieldName}`));
const metadata = get(objectMetadataItemsAtom);
const hoverPos = get(hoverPositionAtom);
return {
value,
isSelected,
isFocused,
isActive,
isDragging,
isPending,
isHovered,
isReadOnly,
isForbidden,
isInputOnly,
metadataCount: metadata.length,
hasHover: hoverPos !== null,
};
}),
(a, b) =>
a.recordId === b.recordId &&
a.fieldName === b.fieldName &&
a.row === b.row &&
a.col === b.col,
);
// ---------------------------------------------------------------------------
// Contexts
// ---------------------------------------------------------------------------
const RowContext = createContext({ recordId: '', rowIndex: 0 });
const CellContext = createContext({ column: 0, fieldName: '' });
const FieldValueContext = createContext('');
// Per-row context provider (simulates RecordTableRowContextProvider)
const PerRowProvider = ({
children,
recordId,
rowIndex,
}: {
children: React.ReactNode;
recordId: string;
rowIndex: number;
}) => (
<RowContext.Provider value={{ recordId, rowIndex }}>
{children}
</RowContext.Provider>
);
// Per-cell context provider (simulates RecordTableCellContext + FieldContext)
const PerCellProvider = ({
children,
column,
fieldName,
}: {
children: React.ReactNode;
column: number;
fieldName: string;
}) => (
<CellContext.Provider value={{ column, fieldName }}>
{children}
</CellContext.Provider>
);
// ---------------------------------------------------------------------------
// Cell variants
// ---------------------------------------------------------------------------
// A. Pure prop passing (no state access)
const CellA_Props = ({ value }: { value: string }) => (
<StyledCell>{value}</StyledCell>
);
// B. One context read
const CellB_OneCtx = () => {
const { recordId } = useContext(RowContext);
return <StyledCell>{recordId}</StyledCell>;
};
// C. Two context reads (row + cell)
const CellC_TwoCtx = () => {
const { recordId } = useContext(RowContext);
const { fieldName } = useContext(CellContext);
return <StyledCell>{recordId}-{fieldName}</StyledCell>;
};
// D. Pre-resolved context value (optimal: data pushed from parent via context)
const CellD_PreResolved = () => {
const value = useContext(FieldValueContext);
return <StyledCell>{value}</StyledCell>;
};
// E. 1 Jotai selector (derived atom from recordAtomFamily)
const CellE_OneSelector = ({
recordId,
fieldName,
}: {
recordId: string;
fieldName: string;
}) => {
const value = useAtomValue(fieldValueSelectorFamily({ recordId, fieldName }));
return <StyledCell>{value}</StyledCell>;
};
// F. 3 Jotai atoms (value + selected + hovered — real cell pattern)
const CellF_ThreeAtoms = ({
recordId,
fieldName,
row,
col,
}: {
recordId: string;
fieldName: string;
row: number;
col: number;
}) => {
const value = useAtomValue(fieldValueSelectorFamily({ recordId, fieldName }));
useAtomValue(rowSelectedFamily(recordId));
useAtomValue(cellIsHoveredFamily({ row, col }));
return <StyledCell>{value}</StyledCell>;
};
// G. 8 Jotai atoms per cell (value + selected + hovered + focused + active + dragging + pending + readOnly)
const CellG_EightAtoms = ({
recordId,
fieldName,
row,
col,
}: {
recordId: string;
fieldName: string;
row: number;
col: number;
}) => {
const value = useAtomValue(fieldValueSelectorFamily({ recordId, fieldName }));
useAtomValue(rowSelectedFamily(recordId));
useAtomValue(cellIsHoveredFamily({ row, col }));
useAtomValue(rowFocusedFamily(recordId));
useAtomValue(rowActiveFamily(recordId));
useAtomValue(rowDraggingFamily(recordId));
useAtomValue(rowPendingFamily(recordId));
useAtomValue(cellReadOnlyFamily(`${recordId}-${fieldName}`));
return <StyledCell>{value}</StyledCell>;
};
// H. 1 heavy derived atom (12 sources — simulates real-world computed cell state)
const CellH_HeavyDerived = ({
recordId,
fieldName,
row,
col,
}: {
recordId: string;
fieldName: string;
row: number;
col: number;
}) => {
const { value } = useAtomValue(
cellHeavyComputedFamily({ recordId, fieldName, row, col }),
);
return <StyledCell>{value}</StyledCell>;
};
// H2. 12 individual atom reads per cell (same sources as H but read separately)
const CellH2_TwelveAtoms = ({
recordId,
fieldName,
row,
col,
}: {
recordId: string;
fieldName: string;
row: number;
col: number;
}) => {
const value = useAtomValue(fieldValueSelectorFamily({ recordId, fieldName }));
useAtomValue(rowSelectedFamily(recordId));
useAtomValue(cellIsHoveredFamily({ row, col }));
useAtomValue(rowFocusedFamily(recordId));
useAtomValue(rowActiveFamily(recordId));
useAtomValue(rowDraggingFamily(recordId));
useAtomValue(rowPendingFamily(recordId));
useAtomValue(cellReadOnlyFamily(`${recordId}-${fieldName}`));
useAtomValue(cellForbiddenFamily(`${recordId}-${fieldName}`));
useAtomValue(cellInputOnlyFamily(`${recordId}-${fieldName}`));
useAtomValue(objectMetadataItemsAtom);
useAtomValue(hoverPositionAtom);
return <StyledCell>{value}</StyledCell>;
};
// I. Context reads + Jotai atoms (real pattern: ctx for position, atoms for data)
const CellI_CtxPlusAtoms = () => {
const { recordId } = useContext(RowContext);
const { fieldName, column } = useContext(CellContext);
const value = useAtomValue(fieldValueSelectorFamily({ recordId, fieldName }));
useAtomValue(rowSelectedFamily(recordId));
return <StyledCell>{value}{column}</StyledCell>;
};
// J. New context value per cell (unstable reference — forces child re-renders)
const UnstableCtxInner = () => {
const value = useContext(FieldValueContext);
return <StyledCell>{value}</StyledCell>;
};
// K. Stable context with memo child
const MemoStyledCell = memo(({ value }: { value: string }) => (
<StyledCell>{value}</StyledCell>
));
MemoStyledCell.displayName = 'MemoStyledCell';
const StableCtxInner = () => {
const value = useContext(FieldValueContext);
return <MemoStyledCell value={value} />;
};
// L. Per-row provider wrapping per-cell providers (real hierarchy pattern)
const CellL_NestedProviders = () => {
const { recordId } = useContext(RowContext);
const { fieldName } = useContext(CellContext);
const value = useAtomValue(fieldValueSelectorFamily({ recordId, fieldName }));
return <StyledCell>{value}</StyledCell>;
};
// ---------------------------------------------------------------------------
// Benchmark definitions
// ---------------------------------------------------------------------------
const FIELD_NAMES = [
'name', 'email', 'phone', 'company', 'city',
'role', 'status', 'score', 'department', 'manager',
];
type BenchmarkDef = {
name: string;
render: (recordIds: string[]) => React.ReactNode;
};
const BENCHMARKS: BenchmarkDef[] = [
{
name: 'A. Props only (baseline)',
render: (recordIds) => (
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<CellA_Props key={`${ri}-${ci}`} value={`${id}-${fn}`} />
)),
)}
</div>
),
},
{
name: 'B. 1 context read',
render: (recordIds) => (
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((_, ci) => (
<RowContext.Provider key={`${ri}-${ci}`} value={{ recordId: id, rowIndex: ri }}>
<CellB_OneCtx />
</RowContext.Provider>
)),
)}
</div>
),
},
{
name: 'C. 2 context reads',
render: (recordIds) => (
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<RowContext.Provider key={`${ri}-${ci}`} value={{ recordId: id, rowIndex: ri }}>
<CellContext.Provider value={{ column: ci, fieldName: fn }}>
<CellC_TwoCtx />
</CellContext.Provider>
</RowContext.Provider>
)),
)}
</div>
),
},
{
name: 'D. Pre-resolved context (optimal)',
render: (recordIds) => (
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<FieldValueContext.Provider key={`${ri}-${ci}`} value={`${id}-${fn}`}>
<CellD_PreResolved />
</FieldValueContext.Provider>
)),
)}
</div>
),
},
{
name: 'E. 1 Jotai selector/cell',
render: (recordIds) => (
<Provider store={perfStore}>
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<CellE_OneSelector key={`${ri}-${ci}`} recordId={id} fieldName={fn} />
)),
)}
</div>
</Provider>
),
},
{
name: 'F. 3 Jotai atoms/cell',
render: (recordIds) => (
<Provider store={perfStore}>
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<CellF_ThreeAtoms
key={`${ri}-${ci}`}
recordId={id}
fieldName={fn}
row={ri}
col={ci}
/>
)),
)}
</div>
</Provider>
),
},
{
name: 'G. 8 Jotai atoms/cell',
render: (recordIds) => (
<Provider store={perfStore}>
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<CellG_EightAtoms
key={`${ri}-${ci}`}
recordId={id}
fieldName={fn}
row={ri}
col={ci}
/>
)),
)}
</div>
</Provider>
),
},
{
name: 'H. 1 derived atom (12 sources)',
render: (recordIds) => (
<Provider store={perfStore}>
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<CellH_HeavyDerived
key={`${ri}-${ci}`}
recordId={id}
fieldName={fn}
row={ri}
col={ci}
/>
)),
)}
</div>
</Provider>
),
},
{
name: 'H2. 12 individual atom reads/cell',
render: (recordIds) => (
<Provider store={perfStore}>
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<CellH2_TwelveAtoms
key={`${ri}-${ci}`}
recordId={id}
fieldName={fn}
row={ri}
col={ci}
/>
)),
)}
</div>
</Provider>
),
},
{
name: 'I. 2 ctx + 2 atoms (real pattern)',
render: (recordIds) => (
<Provider store={perfStore}>
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<RowContext.Provider key={`${ri}-${ci}`} value={{ recordId: id, rowIndex: ri }}>
<CellContext.Provider value={{ column: ci, fieldName: fn }}>
<CellI_CtxPlusAtoms />
</CellContext.Provider>
</RowContext.Provider>
)),
)}
</div>
</Provider>
),
},
{
name: 'J. Unstable ctx value per cell',
render: (recordIds) => (
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<FieldValueContext.Provider key={`${ri}-${ci}`} value={`${id}-${fn}`}>
<UnstableCtxInner />
</FieldValueContext.Provider>
)),
)}
</div>
),
},
{
name: 'K. Stable ctx + React.memo child',
render: (recordIds) => (
<div style={gridCss}>
{recordIds.flatMap((id, ri) =>
FIELD_NAMES.map((fn, ci) => (
<FieldValueContext.Provider key={`${ri}-${ci}`} value={`${id}-${fn}`}>
<StableCtxInner />
</FieldValueContext.Provider>
)),
)}
</div>
),
},
{
name: 'L. Row provider → cell provider → atom (real hierarchy)',
render: (recordIds) => (
<Provider store={perfStore}>
<div style={gridCss}>
{recordIds.map((id, ri) => (
<PerRowProvider key={ri} recordId={id} rowIndex={ri}>
{FIELD_NAMES.map((fn, ci) => (
<PerCellProvider key={ci} column={ci} fieldName={fn}>
<CellL_NestedProviders />
</PerCellProvider>
))}
</PerRowProvider>
))}
</div>
</Provider>
),
},
];
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
type BenchmarkResult = {
name: string;
times: number[];
avg: number;
min: number;
max: number;
};
const generateRecordIds = () =>
Array.from({ length: ROWS }, (_, i) => `rec-${i}`);
export const RecordTableStateAccessAudit = () => {
const [results, setResults] = useState<BenchmarkResult[]>([]);
const [activeTest, setActiveTest] = useState<string | null>(null);
const [testGrid, setTestGrid] = useState<React.ReactNode | null>(null);
const [running, setRunning] = useState(false);
const iterationRef = useRef(0);
const timesRef = useRef<number[]>([]);
const startTimeRef = useRef(0);
const currentBenchRef = useRef<BenchmarkDef | null>(null);
const queueRef = useRef<BenchmarkDef[]>([]);
const recordIdsRef = useRef(generateRecordIds());
const runSingleIteration = useCallback((bench: BenchmarkDef) => {
setActiveTest(`${bench.name} (${iterationRef.current + 1}/${ITERATIONS})`);
startTimeRef.current = performance.now();
setTestGrid(bench.render(recordIdsRef.current));
}, []);
useEffect(() => {
if (!activeTest || startTimeRef.current === 0) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const elapsed = performance.now() - startTimeRef.current;
startTimeRef.current = 0;
timesRef.current.push(elapsed);
iterationRef.current += 1;
if (iterationRef.current < ITERATIONS && currentBenchRef.current) {
setTestGrid(null);
setTimeout(() => runSingleIteration(currentBenchRef.current!), 50);
} else {
const times = [...timesRef.current];
const avg = times.reduce((a, b) => a + b, 0) / times.length;
const min = Math.min(...times);
const max = Math.max(...times);
const benchName = currentBenchRef.current?.name ?? 'unknown';
setResults((prev) => [
...prev,
{ name: benchName, times, avg, min, max },
]);
setTestGrid(null);
setActiveTest(null);
currentBenchRef.current = null;
if (queueRef.current.length > 0) {
const next = queueRef.current.shift()!;
setTimeout(() => startBenchmark(next), 100);
} else {
setRunning(false);
}
}
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTest, testGrid]);
const startBenchmark = useCallback((bench: BenchmarkDef) => {
currentBenchRef.current = bench;
iterationRef.current = 0;
timesRef.current = [];
runSingleIteration(bench);
}, [runSingleIteration]);
const runAll = useCallback(() => {
setResults([]);
setRunning(true);
queueRef.current = [...BENCHMARKS.slice(1)];
startBenchmark(BENCHMARKS[0]);
}, [startBenchmark]);
const runOne = useCallback(
(bench: BenchmarkDef) => {
setRunning(true);
queueRef.current = [];
startBenchmark(bench);
},
[startBenchmark],
);
const perCell = (ms: number) => (ms / TOTAL_CELLS).toFixed(3);
const overhead = (avg: number) => {
if (results.length === 0) return '';
const baseline = results[0].avg;
const delta = avg - baseline;
const pct = ((delta / baseline) * 100).toFixed(0);
return `+${delta.toFixed(1)}ms (+${pct}%)`;
};
return (
<div style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>
<h2 style={{ marginBottom: 4 }}>State Access Pattern Audit</h2>
<p style={{ color: '#666', marginTop: 0 }}>
<strong>{TOTAL_CELLS.toLocaleString()} cells</strong> ({ROWS} rows x {COLS} cols),
{ITERATIONS} iterations each. Avg = total mount time.
Per cell = avg / {TOTAL_CELLS.toLocaleString()}.
</p>
<div style={{ marginBottom: 12 }}>
<button
onClick={runAll}
disabled={running}
style={{
padding: '8px 16px',
fontWeight: 'bold',
fontSize: 14,
background: running ? '#ccc' : '#2563eb',
color: 'white',
border: 'none',
borderRadius: 4,
cursor: running ? 'default' : 'pointer',
marginRight: 8,
}}
>
{running ? `Running: ${activeTest ?? '...'}` : 'Run All Benchmarks'}
</button>
<button
onClick={() => setResults([])}
disabled={running}
style={{ padding: '8px 12px', fontSize: 13 }}
>
Clear
</button>
</div>
<details style={{ marginBottom: 16 }}>
<summary style={{ cursor: 'pointer', fontWeight: 600, fontSize: 13 }}>
Run individual tests
</summary>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 8 }}>
{BENCHMARKS.map((bench) => (
<button
key={bench.name}
onClick={() => runOne(bench)}
disabled={running}
style={{ fontSize: 11, padding: '4px 8px' }}
>
{bench.name}
</button>
))}
</div>
</details>
{results.length > 0 && (
<div
style={{
marginBottom: 16,
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 6,
overflow: 'auto',
}}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f0f7ff' }}>
<th style={{ textAlign: 'left', padding: '8px 12px' }}>Test</th>
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Avg</th>
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Min</th>
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Max</th>
<th style={{ textAlign: 'right', padding: '8px 12px' }}>Avg/cell</th>
<th style={{ textAlign: 'right', padding: '8px 12px' }}>vs baseline</th>
<th style={{ textAlign: 'left', padding: '8px 12px', width: 200 }}>Bar</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => {
const maxAvg = Math.max(...results.map((x) => x.avg));
const barPct = (r.avg / maxAvg) * 100;
const isBaseline = i === 0;
return (
<tr
key={r.name}
style={{
borderTop: '1px solid #e2e8f0',
background: isBaseline ? '#eff6ff' : undefined,
}}
>
<td style={{ padding: '6px 12px', fontWeight: isBaseline ? 600 : 400 }}>
{r.name}
</td>
<td style={{ textAlign: 'right', padding: '6px 12px', fontWeight: 600 }}>
{r.avg.toFixed(1)}ms
</td>
<td style={{ textAlign: 'right', padding: '6px 12px', color: '#666' }}>
{r.min.toFixed(1)}
</td>
<td style={{ textAlign: 'right', padding: '6px 12px', color: '#666' }}>
{r.max.toFixed(1)}
</td>
<td style={{ textAlign: 'right', padding: '6px 12px', color: '#666' }}>
{perCell(r.avg)}ms
</td>
<td
style={{
textAlign: 'right',
padding: '6px 12px',
color: isBaseline ? '#666' : '#dc2626',
fontWeight: isBaseline ? 400 : 500,
}}
>
{isBaseline ? '—' : overhead(r.avg)}
</td>
<td style={{ padding: '6px 12px' }}>
<div
style={{
height: 16,
width: `${barPct}%`,
background: isBaseline
? '#93c5fd'
: barPct > 80
? '#fca5a5'
: barPct > 50
? '#fcd34d'
: '#86efac',
borderRadius: 2,
transition: 'width 0.3s',
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{activeTest && !results.length && (
<p style={{ color: '#666' }}>Running: {activeTest}...</p>
)}
<div style={{ position: 'absolute', left: -9999, top: -9999, visibility: 'hidden' }}>
{testGrid}
</div>
</div>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

@@ -0,0 +1,88 @@
import { styled } from '@linaria/react';
export const LinariaStaticCell = styled.div`
height: 32px;
border-bottom: 1px solid #eee;
border-right: 1px solid #eee;
padding: 0 8px;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
font-size: 13px;
`;
export const LinariaDynamicCell = styled.div<{
bgColor: string;
borderColor: string;
fontColor: string;
isReadOnly: boolean;
}>`
height: 32px;
border-bottom: 1px solid ${({ borderColor }) => borderColor};
border-right: 1px solid ${({ borderColor }) => borderColor};
padding: 0 8px;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
font-size: 13px;
background: ${({ bgColor }) => bgColor};
color: ${({ fontColor }) => fontColor};
cursor: ${({ isReadOnly }) => (isReadOnly ? 'default' : 'pointer')};
`;
export const LinariaOuterWrapper = styled.div<{
backgroundColor: string;
borderColor: string;
}>`
border-bottom: 1px solid ${({ borderColor }) => borderColor};
border-right: 1px solid ${({ borderColor }) => borderColor};
padding: 0;
text-align: left;
background: ${({ backgroundColor }) => backgroundColor};
`;
export const LinariaBaseContainer = styled.div<{
fontColorMedium: string;
bgSecondary: string;
fontColorSecondary: string;
isReadOnly: boolean;
}>`
align-items: center;
box-sizing: border-box;
cursor: ${({ isReadOnly }) => (isReadOnly ? 'default' : 'pointer')};
display: flex;
height: 32px;
user-select: none;
position: relative;
&:hover {
${(props) => {
if (!props.isReadOnly) return '';
return `
outline: 1px solid ${props.fontColorMedium};
background-color: ${props.bgSecondary};
color: ${props.fontColorSecondary};
`;
}}
}
`;
export const LinariaDisplayOuter = styled.div`
align-items: center;
display: flex;
height: 100%;
overflow: hidden;
padding-left: 8px;
width: 100%;
`;
export const LinariaDisplayInner = styled.div`
align-items: center;
display: flex;
height: 100%;
overflow: hidden;
width: 100%;
white-space: nowrap;
`;
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react';
// Drop-in profiler to measure render times of individual components.
// Import and call at the top of any component:
// useRecordTableRenderProfiler('RecordTableCell');
//
// Results are batched and logged every 2 seconds to avoid console spam.
// Enable by setting window.__RECORD_TABLE_PROFILE = true in DevTools console.
type TimingEntry = {
component: string;
renderMs: number;
};
const timingBuffer: TimingEntry[] = [];
let flushScheduled = false;
const flushTimings = () => {
if (timingBuffer.length === 0) return;
const grouped: Record<string, { count: number; totalMs: number; maxMs: number }> = {};
for (const entry of timingBuffer) {
const existing = grouped[entry.component];
if (existing) {
existing.count++;
existing.totalMs += entry.renderMs;
existing.maxMs = Math.max(existing.maxMs, entry.renderMs);
} else {
grouped[entry.component] = {
count: 1,
totalMs: entry.renderMs,
maxMs: entry.renderMs,
};
}
}
// eslint-disable-next-line no-console
console.group(`[RecordTable Profiler] ${timingBuffer.length} renders`);
for (const [name, stats] of Object.entries(grouped).sort(
(a, b) => b[1].totalMs - a[1].totalMs,
)) {
// eslint-disable-next-line no-console
console.log(
`${name}: ${stats.count} renders, ` +
`total=${stats.totalMs.toFixed(2)}ms, ` +
`avg=${(stats.totalMs / stats.count).toFixed(3)}ms, ` +
`max=${stats.maxMs.toFixed(3)}ms`,
);
}
// eslint-disable-next-line no-console
console.groupEnd();
timingBuffer.length = 0;
flushScheduled = false;
};
const scheduleFlush = () => {
if (!flushScheduled) {
flushScheduled = true;
setTimeout(flushTimings, 2000);
}
};
declare global {
interface Window {
__RECORD_TABLE_PROFILE?: boolean;
}
}
export const useRecordTableRenderProfiler = (componentName: string) => {
const startTime = useRef(performance.now());
startTime.current = performance.now();
useEffect(() => {
if (!window.__RECORD_TABLE_PROFILE) return;
const elapsed = performance.now() - startTime.current;
timingBuffer.push({ component: componentName, renderMs: elapsed });
scheduleFlush();
});
};
@@ -18,6 +18,7 @@ import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSe
import styled from '@emotion/styled';
import { useCallback, useRef, useState } from 'react';
import { useStore } from 'jotai';
import { type TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
const StyledTableContainer = styled.div`
display: flex;
@@ -91,6 +92,40 @@ export const RecordTableContent = ({
}
}, [store, isSomeCellInEditMode, setRecordTableHoverPosition]);
const lastHoverPositionRef = useRef<TableCellPosition | null>(null);
const handleDelegatedMouseMove = useCallback(
(event: React.MouseEvent) => {
const target = event.target as HTMLElement;
const cellElement = target.closest<HTMLElement>(
'[id^="record-table-cell-"]',
);
if (!cellElement) {
return;
}
const idParts = cellElement.id.split('-');
const column = parseInt(idParts[3], 10);
const row = parseInt(idParts[4], 10);
if (isNaN(column) || isNaN(row)) {
return;
}
const lastPosition = lastHoverPositionRef.current;
if (lastPosition?.column === column && lastPosition?.row === row) {
return;
}
const position = { column, row };
lastHoverPositionRef.current = position;
setRecordTableHoverPosition(position);
},
[setRecordTableHoverPosition],
);
return (
<StyledTableContainer ref={containerRef}>
<RecordTableStyleWrapper
@@ -98,6 +133,7 @@ export const RecordTableContent = ({
isDragging={isDragging}
visibleRecordFields={visibleRecordFields}
id={RECORD_TABLE_HTML_ID}
onMouseMove={handleDelegatedMouseMove}
onMouseLeave={handleMouseLeave}
hasRecordGroups={hasRecordGroups}
>
@@ -1,6 +1,7 @@
import { type ReactNode } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { RecordTableContextProvider as RecordTableContextInternalProvider } from '@/object-record/record-table/contexts/RecordTableContext';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
@@ -32,6 +33,8 @@ export const RecordTableContextProvider = ({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
@@ -57,6 +60,7 @@ export const RecordTableContextProvider = ({
value={{
viewBarId,
objectMetadataItem,
objectMetadataItems,
recordTableId,
objectNameSingular,
objectPermissions,
@@ -163,6 +163,7 @@ const meta: Meta = {
viewBarId: mockPerformance.recordId,
// TODO: update performance mocks with new data, and merge with common mocks if possible
objectMetadataItem: mockPerformance.objectMetadataItem as any,
objectMetadataItems: [],
objectNameSingular:
mockPerformance.objectMetadataItem.nameSingular,
objectPermissions: {
@@ -8,6 +8,7 @@ type RecordTableContextValue = {
viewBarId: string;
objectNameSingular: string;
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
objectPermissions: ObjectPermission;
visibleRecordFields: RecordField[];
onRecordIdentifierClick?: (rowIndex: number, recordId: string) => void;
@@ -1,11 +1,11 @@
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldFocusContextProvider } from '@/object-record/record-field/ui/contexts/FieldFocusContextProvider';
import { FieldFocusStaticUnfocusedProvider } from '@/object-record/record-field/ui/contexts/FieldFocusContextProvider';
import { RecordTableCellContainer } from '@/object-record/record-table/record-table-cell/components/RecordTableCellContainer';
export const RecordTableCell = () => {
return (
<FieldFocusContextProvider>
<FieldFocusStaticUnfocusedProvider>
<RecordTableCellContainer nonEditModeContent={<FieldDisplay />} />
</FieldFocusContextProvider>
</FieldFocusStaticUnfocusedProvider>
);
};
@@ -2,11 +2,10 @@ import { styled } from '@linaria/react';
import { useContext, type ReactNode } from 'react';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
import { useRecordTableRenderProfiler } from '@/object-record/record-table/__perf__/useRecordTableRenderProfiler';
import { isFieldIdentifierDisplay } from '@/object-record/record-field/ui/meta-types/display/utils/isFieldIdentifierDisplay';
import { RECORD_CHIP_CLICK_OUTSIDE_ID } from '@/object-record/record-table/constants/RecordChipClickOutsideId';
import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight';
import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { ThemeContext } from 'twenty-ui/theme';
@@ -55,12 +54,12 @@ export const RecordTableCellBaseContainer = ({
}: {
children: ReactNode;
}) => {
useRecordTableRenderProfiler('RecordTableCellBaseContainer');
const {
isRecordFieldReadOnly: isReadOnly,
fieldDefinition,
isLabelIdentifier,
} = useContext(FieldContext);
const { setIsFocused } = useFieldFocus();
const { openTableCell } = useOpenRecordTableCellFromCell();
const { theme } = useContext(ThemeContext);
@@ -70,26 +69,13 @@ export const RecordTableCellBaseContainer = ({
fieldDefinition,
isLabelIdentifier,
);
const { onMoveHoverToCurrentCell } = useRecordTableBodyContextOrThrow();
const handleContainerMouseMove = () => {
setIsFocused(true);
onMoveHoverToCurrentCell(cellPosition);
};
const handleContainerMouseLeave = () => {
setIsFocused(false);
};
const handleContainerClick = () => {
onMoveHoverToCurrentCell(cellPosition);
openTableCell();
};
return (
<StyledBaseContainer
onMouseLeave={handleContainerMouseLeave}
onMouseMove={handleContainerMouseMove}
onClick={handleContainerClick}
backgroundColorTransparentSecondary={
theme.background.transparent.secondary
@@ -2,6 +2,7 @@ import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldCont
import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly';
import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext';
import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { useRecordTableRenderProfiler } from '@/object-record/record-table/__perf__/useRecordTableRenderProfiler';
import { useContext, type ReactNode } from 'react';
import { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer';
@@ -10,6 +11,7 @@ export const RecordTableCellDisplayMode = ({
}: {
children: ReactNode;
}) => {
useRecordTableRenderProfiler('RecordTableCellDisplayMode');
const { recordId, isRecordFieldReadOnly: isReadOnly } =
useContext(FieldContext);
@@ -1,4 +1,3 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly';
@@ -15,6 +14,7 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useContext, type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useRecordTableRenderProfiler } from '@/object-record/record-table/__perf__/useRecordTableRenderProfiler';
type RecordTableCellFieldContextGenericProps = {
recordField: RecordField;
@@ -25,9 +25,10 @@ export const RecordTableCellFieldContextGeneric = ({
recordField,
children,
}: RecordTableCellFieldContextGenericProps) => {
useRecordTableRenderProfiler('RecordTableCellFieldContextGeneric');
const { recordId, isRecordReadOnly } = useRecordTableRowContextOrThrow();
const { objectMetadataItem, objectPermissions } =
const { objectMetadataItem, objectMetadataItems, objectPermissions } =
useRecordTableContextOrThrow();
const {
@@ -35,8 +36,6 @@ export const RecordTableCellFieldContextGeneric = ({
fieldDefinitionByFieldMetadataItemId,
} = useRecordIndexContextOrThrow();
const { objectMetadataItems } = useObjectMetadataItems();
const fieldDefinition =
fieldDefinitionByFieldMetadataItemId[recordField.fieldMetadataItemId];
@@ -1,5 +1,6 @@
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { FieldFocusStaticFocusedProvider } from '@/object-record/record-field/ui/contexts/FieldFocusContextProvider';
import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly';
import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight';
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
@@ -78,15 +79,17 @@ export const RecordTableCellHoveredPortalContent = () => {
showInteractiveStyle={showInteractiveStyle}
isRecordTableRowActive={isRecordTableRowActive}
>
{isFieldInputOnly ? (
<RecordTableCellEditMode>
<RecordTableCellFieldInput />
</RecordTableCellEditMode>
) : (
<RecordTableCellDisplayMode>
<FieldDisplay />
</RecordTableCellDisplayMode>
)}
<FieldFocusStaticFocusedProvider>
{isFieldInputOnly ? (
<RecordTableCellEditMode>
<RecordTableCellFieldInput />
</RecordTableCellEditMode>
) : (
<RecordTableCellDisplayMode>
<FieldDisplay />
</RecordTableCellDisplayMode>
)}
</FieldFocusStaticFocusedProvider>
{showButton && <RecordTableCellEditButton />}
</StyledRecordTableCellHoveredPortalContent>
);
@@ -9,8 +9,10 @@ import { RecordTableCellWrapper } from '@/object-record/record-table/record-tabl
import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { useRecordTableRenderProfiler } from '@/object-record/record-table/__perf__/useRecordTableRenderProfiler';
export const RecordTableFieldsCells = () => {
useRecordTableRenderProfiler('RecordTableFieldsCells');
const { isSelected, rowIndex } = useRecordTableRowContextOrThrow();
const { isDragging } = useRecordTableRowDraggableContextOrThrow();
@@ -15,6 +15,7 @@ import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { forwardRef, type ReactNode } from 'react';
import { useRecordTableRenderProfiler } from '@/object-record/record-table/__perf__/useRecordTableRenderProfiler';
type RecordTableTrProps = {
children: ReactNode;
@@ -39,6 +40,7 @@ export const RecordTableTr = forwardRef<HTMLDivElement, RecordTableTrProps>(
},
ref,
) => {
useRecordTableRenderProfiler('RecordTableTr');
const { objectMetadataItem } = useRecordTableContextOrThrow();
const isRowSelected = useAtomComponentFamilyStateValue(
@@ -0,0 +1,62 @@
import { ThemeProvider } from '@emotion/react';
import { RecordTableCellPerformanceAudit } from '@/object-record/record-table/__perf__/RecordTableCellPerformanceAudit';
import { RecordTableStateAccessAudit } from '@/object-record/record-table/__perf__/RecordTableStateAccessAudit';
import { useState } from 'react';
import { THEME_LIGHT, ThemeContextProvider } from 'twenty-ui/theme';
export const RecordTablePerfPage = () => {
const [tab, setTab] = useState<'cell' | 'state'>('cell');
return (
<ThemeProvider theme={THEME_LIGHT}>
<ThemeContextProvider theme={THEME_LIGHT}>
<div
style={{
fontFamily: 'system-ui, sans-serif',
minHeight: '100vh',
background: '#fff',
}}
>
<div
style={{
display: 'flex',
gap: 0,
borderBottom: '1px solid #e2e8f0',
padding: '0 16px',
background: '#f8fafc',
}}
>
{(['cell', 'state'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
padding: '12px 20px',
fontSize: 14,
fontWeight: tab === t ? 600 : 400,
color: tab === t ? '#2563eb' : '#64748b',
background: 'transparent',
border: 'none',
borderBottom:
tab === t
? '2px solid #2563eb'
: '2px solid transparent',
cursor: 'pointer',
}}
>
{t === 'cell'
? 'Cell Render Benchmark'
: 'State Access Benchmark'}
</button>
))}
</div>
{tab === 'cell' ? (
<RecordTableCellPerformanceAudit />
) : (
<RecordTableStateAccessAudit />
)}
</div>
</ThemeContextProvider>
</ThemeProvider>
);
};
@@ -104,6 +104,7 @@ const InternalTableContextProviders = ({
children: React.ReactNode;
objectMetadataItem: ObjectMetadataItem;
}) => {
const allObjectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const currentRecordFields = useAtomComponentStateValue(
@@ -174,6 +175,7 @@ const InternalTableContextProviders = ({
value={{
objectNameSingular: objectMetadataItem.nameSingular,
objectMetadataItem: objectMetadataItem,
objectMetadataItems: allObjectMetadataItems,
recordTableId: objectMetadataItem.namePlural,
viewBarId: 'view-bar',
objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({
+1
View File
@@ -143,6 +143,7 @@ export default defineConfig(({ command, mode }) => {
'**/RecordTableRowVirtualizedContainer.tsx',
'**/RecordTableVirtualizedBodyPlaceholder.tsx',
'**/RecordTableCellLoading.tsx',
'**/perf-linaria-cells.tsx',
],
babelOptions: {
presets: ['@babel/preset-typescript', '@babel/preset-react'],