Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3311ca1a58 | |||
| 718a57172a | |||
| cb128c4158 | |||
| 0288545d7b | |||
| 996947a3cb | |||
| 05917e23a9 | |||
| 3f0cfe66d1 | |||
| 30dc70a943 | |||
| 7221b45ff7 | |||
| 49d05f3ca7 | |||
| 0e44e07fcf |
@@ -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>
|
||||
</>,
|
||||
),
|
||||
);
|
||||
|
||||
+35
@@ -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>
|
||||
);
|
||||
|
||||
+266
@@ -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:
|
||||
|
||||

|
||||
|
||||
Key findings (2,000 cells):
|
||||
- Styling engines (01–07): Linaria and Emotion are comparable (~40–45ms). Emotion theme interpolation adds ~12% overhead.
|
||||
- Context reads (08–09) and useState (10–11) 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:
|
||||
|
||||

|
||||
|
||||
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
|
||||
+1144
File diff suppressed because it is too large
Load Diff
+92
@@ -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>
|
||||
);
|
||||
};
|
||||
+831
@@ -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>
|
||||
);
|
||||
};
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
+88
@@ -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;
|
||||
`;
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
+82
@@ -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();
|
||||
});
|
||||
};
|
||||
+36
@@ -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}
|
||||
>
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+1
@@ -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: {
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ type RecordTableContextValue = {
|
||||
viewBarId: string;
|
||||
objectNameSingular: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
objectMetadataItems: ObjectMetadataItem[];
|
||||
objectPermissions: ObjectPermission;
|
||||
visibleRecordFields: RecordField[];
|
||||
onRecordIdentifierClick?: (rowIndex: number, recordId: string) => void;
|
||||
|
||||
+3
-3
@@ -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
-16
@@ -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
@@ -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);
|
||||
|
||||
|
||||
+3
-4
@@ -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];
|
||||
|
||||
|
||||
+12
-9
@@ -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>
|
||||
);
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
+2
@@ -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({
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user