ece7a384df
## Problem On twenty-main, loading a record-index/standalone page for the first time renders the page chrome (title, view chip with record count) but the table body stays blank. A subsequent reload fixes it. Regression introduced by the cache-first `currentUser` bootstrap (#21532); follow-up to #21592, which already mentioned the experiment "should be reviewed." ## Root cause (concurrency) The metadata loader runs in two phases and the gate opens between them: 1. **`loadMinimalMetadata`** fast-paths `objectMetadataItems` and `views` to `status: 'up-to-date'` with only their *minimal* fields. `viewFields` and `fieldMetadataItems` stay `'empty'`. 2. **`IsMinimalMetadataReadyEffect`** opens the gate as soon as those two are `'up-to-date'` — before viewFields exist. 3. The page mounts. `viewsSelector` joins views with an empty `viewFields` collection, so `view.viewFields = []`. `RecordIndexLoadBaseOnContextStoreEffect` calls `loadRecordIndexStates(view, …)` with the empty viewFields and pins `loadedViewId === contextStoreCurrentViewId`. 4. `loadStaleMetadataEntities` later populates viewFields; the selector recomputes, but the effect bails out on the `loadedViewId` guard. `currentRecordFields` stays empty. 5. `visibleRecordFields` stays empty → `RecordTableVirtualizedInitialDataLoadEffect` hits its `isEmpty(visibleRecordFields)` guard and never fetches → empty body. The "300" count visible in the screenshot comes from `useGetRecordIndexTotalCount`'s separate aggregate query, which doesn't depend on viewFields. **Why the gate close/reopen self-heal doesn't work reliably:** `replaceDraft → applyChanges` happen in the same microtask chain. React 18 automatic batching collapses both into a single render where status goes `'empty' → 'up-to-date'` without an intermediate `'draft-pending'` observable to React. The gate never closes, children never unmount, `loadedViewId` is never reset. **Why it surfaced after #21532:** Before, `currentUser` was loaded only after `GetCurrentUser` returned — by which time `loadStaleMetadataEntities` had typically also completed and viewFields were populated when the gate opened. Now the cached `currentUser` lets the gate open the moment `loadMinimalMetadata` finishes. ## Fix Extend `IsMinimalMetadataReadyEffect` to also require `fieldMetadataItems` and `viewFields` to be `'up-to-date'` before opening the gate. Both are joined into the data the record-index page reads on first paint (`objectMetadataItemsWithFieldsSelector` reads fieldMetadataItems; `viewsSelector` reads viewFields), so the page can't render correctly without them. - **Warm cache** (all entities hydrated `up-to-date` from IndexedDB): unaffected — gate opens immediately. - **Cold cache and the first load post–IndexedDB-migration**: the gate stays closed until `loadStaleMetadataEntities` + `applyChanges` finish, then opens with full metadata. The page mounts once with a populated view; no race. ## Tests - [x] `nx typecheck twenty-front` clean (file change passes `oxlint` on the touched file). - [ ] Manual on twenty-main: cold reload + first navigation to a record-index page renders the table body without needing a second reload. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DD3469JAWYURa2sKUTJ85e --- _Generated by [Claude Code](https://claude.ai/code/session_01DD3469JAWYURa2sKUTJ85e)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21713?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Claude <noreply@anthropic.com>
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
import { RuleTester } from 'oxlint/plugins-dev';
|
|
import { rule, RULE_NAME } from './matching-state-variable';
|
|
|
|
const ruleTester = new RuleTester();
|
|
|
|
ruleTester.run(RULE_NAME, rule, {
|
|
valid: [
|
|
{
|
|
code: 'const variable = useAtomStateValue(variableState);',
|
|
},
|
|
{
|
|
code: 'const [variable, setVariable] = useAtomState(variableState);',
|
|
},
|
|
{
|
|
code: 'const variable = useAtomComponentStateValue(variableComponentState);',
|
|
},
|
|
{
|
|
code: 'const [variable, setVariable] = useAtomComponentState(variableComponentState);',
|
|
},
|
|
{
|
|
code: 'const variable = useAtomFamilyStateValue(variableFamilyState, key);',
|
|
},
|
|
{
|
|
code: 'const variable = useAtomComponentFamilyStateValue(variableComponentFamilyState, key);',
|
|
},
|
|
{
|
|
code: 'const [variable, setVariable] = useAtomComponentFamilyState(variableComponentFamilyState, key);',
|
|
},
|
|
{
|
|
code: "const variableViews = useAtomFamilyStateValue(variableFamilyState, 'views');",
|
|
},
|
|
{
|
|
code: "const variableViewFields = useAtomFamilyStateValue(variableFamilyState, 'viewFields');",
|
|
},
|
|
{
|
|
code: "const [variableViews, setVariableViews] = useAtomComponentFamilyState(variableComponentFamilyState, 'views');",
|
|
},
|
|
{
|
|
code: "const setVariableViews = useSetAtomFamilyState(variableFamilyState, 'views');",
|
|
},
|
|
{
|
|
code: 'const setVariable = useSetAtomState(variableState);',
|
|
},
|
|
{
|
|
code: 'const setVariable = useSetAtomComponentState(variableComponentState);',
|
|
},
|
|
{
|
|
code: 'const setVariable = useSetAtomFamilyState(variableFamilyState, key);',
|
|
},
|
|
{
|
|
code: 'const setVariable = useSetAtomComponentFamilyState(variableComponentFamilyState, key);',
|
|
},
|
|
],
|
|
invalid: [
|
|
{
|
|
code: 'const myValue = useAtomStateValue(variableState);',
|
|
output: 'const variable = useAtomStateValue(variableState);',
|
|
errors: [{ messageId: 'invalidVariableName' }],
|
|
},
|
|
{
|
|
code: 'const [myValue, setMyValue] = useAtomState(variableState);',
|
|
output: 'const [variable, setVariable] = useAtomState(variableState);',
|
|
errors: [
|
|
{ messageId: 'invalidVariableName' },
|
|
{ messageId: 'invalidSetterName' },
|
|
],
|
|
},
|
|
{
|
|
code: 'const [myValue] = useAtomState(variableState);',
|
|
output: 'const [variable] = useAtomState(variableState);',
|
|
errors: [{ messageId: 'invalidVariableName' }],
|
|
},
|
|
{
|
|
code: 'const [, setMyValue] = useAtomState(variableState);',
|
|
output: 'const [, setVariable] = useAtomState(variableState);',
|
|
errors: [{ messageId: 'invalidSetterName' }],
|
|
},
|
|
{
|
|
code: 'const myValue = useAtomComponentStateValue(variableComponentState);',
|
|
output: 'const variable = useAtomComponentStateValue(variableComponentState);',
|
|
errors: [{ messageId: 'invalidVariableName' }],
|
|
},
|
|
{
|
|
code: 'const [myValue, setMyValue] = useAtomComponentState(variableComponentState);',
|
|
output: 'const [variable, setVariable] = useAtomComponentState(variableComponentState);',
|
|
errors: [
|
|
{ messageId: 'invalidVariableName' },
|
|
{ messageId: 'invalidSetterName' },
|
|
],
|
|
},
|
|
{
|
|
code: 'const myValue = useAtomFamilyStateValue(variableFamilyState, key);',
|
|
output: 'const variable = useAtomFamilyStateValue(variableFamilyState, key);',
|
|
errors: [{ messageId: 'invalidVariableName' }],
|
|
},
|
|
{
|
|
code: 'const myValue = useAtomComponentFamilyStateValue(variableComponentFamilyState, key);',
|
|
output: 'const variable = useAtomComponentFamilyStateValue(variableComponentFamilyState, key);',
|
|
errors: [{ messageId: 'invalidVariableName' }],
|
|
},
|
|
{
|
|
code: 'const [myValue, setMyValue] = useAtomComponentFamilyState(variableComponentFamilyState, key);',
|
|
output: 'const [variable, setVariable] = useAtomComponentFamilyState(variableComponentFamilyState, key);',
|
|
errors: [
|
|
{ messageId: 'invalidVariableName' },
|
|
{ messageId: 'invalidSetterName' },
|
|
],
|
|
},
|
|
{
|
|
code: 'const myValue = useSetAtomState(variableState);',
|
|
output: 'const setVariable = useSetAtomState(variableState);',
|
|
errors: [{ messageId: 'invalidSetterName' }],
|
|
},
|
|
{
|
|
code: 'const myValue = useSetAtomComponentState(variableComponentState);',
|
|
output: 'const setVariable = useSetAtomComponentState(variableComponentState);',
|
|
errors: [{ messageId: 'invalidSetterName' }],
|
|
},
|
|
{
|
|
code: 'const myValue = useSetAtomFamilyState(variableFamilyState, key);',
|
|
output: 'const setVariable = useSetAtomFamilyState(variableFamilyState, key);',
|
|
errors: [{ messageId: 'invalidSetterName' }],
|
|
},
|
|
{
|
|
code: 'const myValue = useSetAtomComponentFamilyState(variableComponentFamilyState, key);',
|
|
output: 'const setVariable = useSetAtomComponentFamilyState(variableComponentFamilyState, key);',
|
|
errors: [{ messageId: 'invalidSetterName' }],
|
|
},
|
|
{
|
|
code: "const variableSorts = useAtomFamilyStateValue(variableFamilyState, 'views');",
|
|
output: "const variable = useAtomFamilyStateValue(variableFamilyState, 'views');",
|
|
errors: [{ messageId: 'invalidVariableName' }],
|
|
},
|
|
],
|
|
});
|