Skip to content

Commit 0392152

Browse files
layershifterclaude
andauthored
test(react-context-selector): add cypress regression for eager-bailout (React 18) (#36033)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 96a5309 commit 0392152

7 files changed

Lines changed: 133 additions & 1 deletion

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "none",
3+
"comment": "test: add cypress regression coverage for useContextSelector eager-bailout (React 18)",
4+
"packageName": "@fluentui/react-context-selector",
5+
"email": "olfedias@microsoft.com",
6+
"dependentChangeType": "none"
7+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { baseConfig } from '@fluentui/scripts-cypress';
2+
3+
export default baseConfig;

packages/react-components/react-context-selector/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
"@fluentui/react-utilities": "^9.26.2",
1616
"@swc/helpers": "^0.5.1"
1717
},
18+
"devDependencies": {
19+
"@fluentui/scripts-cypress": "*"
20+
},
1821
"peerDependencies": {
1922
"@types/react": ">=16.14.0 <20.0.0",
2023
"@types/react-dom": ">=16.9.0 <20.0.0",
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { mount as mountBase } from '@fluentui/scripts-cypress';
2+
import * as React from 'react';
3+
4+
import { createContext } from './createContext';
5+
import { useContextSelector } from './useContextSelector';
6+
7+
// Render-count assertions are sensitive to StrictMode's intentional double-invoke.
8+
// This test validates a lane-pollution behavior that is orthogonal to StrictMode,
9+
// so we disable it to keep counts deterministic and 1:1 with commits.
10+
const mount = (element: React.ReactElement) => mountBase(element, { strict: false });
11+
12+
// Module-level render counter. Mutated inside the render body to capture
13+
// function-component invocations that `useEffect` cannot observe: under the
14+
// v1 `useState`-bailout hook on React 18, React runs the component function
15+
// and then discards the render via `bailoutOnAlreadyFinishedWork`, so no
16+
// commit happens — but the function already ran.
17+
type Index = 1 | 2 | 3 | 4;
18+
const RENDER_COUNTS: Record<Index, number> = { 1: 0, 2: 0, 3: 0, 4: 0 };
19+
const resetRenderCounts = () => {
20+
RENDER_COUNTS[1] = 0;
21+
RENDER_COUNTS[2] = 0;
22+
RENDER_COUNTS[3] = 0;
23+
RENDER_COUNTS[4] = 0;
24+
};
25+
26+
const TestContext = createContext<{ index: number }>({ index: -1 });
27+
28+
const Item: React.FC<{ index: Index }> = props => {
29+
const active = useContextSelector(TestContext, value => value.index === props.index);
30+
RENDER_COUNTS[props.index] += 1;
31+
return (
32+
<div data-testid={`item-${props.index}`} data-active={String(active)}>
33+
item {props.index}
34+
</div>
35+
);
36+
};
37+
38+
const MemoItem = React.memo(Item);
39+
40+
const Provider: React.FC<{ children?: React.ReactNode }> = props => {
41+
const [index, setIndex] = React.useState(0);
42+
return (
43+
<button type="button" data-testid="provider" onClick={() => setIndex(prev => prev + 1)}>
44+
<TestContext.Provider value={{ index }}>{props.children}</TestContext.Provider>
45+
</button>
46+
);
47+
};
48+
49+
// Regression test for the `useState` eager-bailout pitfall described in
50+
// docs/react-v9/contributing/rfcs/react-components/context-selector-tearing.md.
51+
//
52+
// On React 18, a bound-at-mount fiber's alternate retains lanes from a prior
53+
// listener-driven `setState`. On the next listener-driven `setState(prev => prev)`
54+
// the eager-bailout precondition (`fiber.lanes === NoLanes && alternate.lanes === NoLanes`)
55+
// fails, so React enqueues the update, enters `beginWork`, runs the component
56+
// function, and only then discards the JSX via `bailoutOnAlreadyFinishedWork`.
57+
// The DOM never changes — but the function already ran. A `useEffect` cannot
58+
// observe this leak because no commit happens. The in-render counter can.
59+
//
60+
// This is a React-18-only glitch (React 19 relaxed the precondition), so the
61+
// test is most meaningful under `test-rit--18--e2e`. On React 17/18 against
62+
// the legacy `useState`-bailout hook, `item-1`'s render count grows from 3 to 4
63+
// on click 3.
64+
describe('useContextSelector — eager-bailout regression', () => {
65+
beforeEach(() => {
66+
// Cypress reuses the component iframe across retries within the same spec,
67+
// so the module-level counter accumulates across attempts. Reset it so
68+
// assertions are absolute, not cumulative.
69+
resetRenderCounts();
70+
});
71+
72+
it('memoized consumers whose selected slice did not change must not execute their render function', () => {
73+
mount(
74+
<Provider>
75+
<MemoItem index={1} />
76+
<MemoItem index={2} />
77+
<MemoItem index={3} />
78+
<MemoItem index={4} />
79+
</Provider>,
80+
);
81+
82+
// Mount: each item's function body ran once.
83+
cy.wrap(RENDER_COUNTS).should('deep.equal', { 1: 1, 2: 1, 3: 1, 4: 1 });
84+
85+
// Click 1: 0 → 1. Only item 1 flips (false → true).
86+
cy.get('[data-testid=provider]').click();
87+
cy.get('[data-testid=item-1]').should('have.attr', 'data-active', 'true');
88+
cy.wrap(RENDER_COUNTS).should('deep.equal', { 1: 2, 2: 1, 3: 1, 4: 1 });
89+
90+
// Click 2: 1 → 2. Item 1 flips (true → false), item 2 flips (false → true).
91+
cy.get('[data-testid=provider]').click();
92+
cy.get('[data-testid=item-1]').should('have.attr', 'data-active', 'false');
93+
cy.get('[data-testid=item-2]').should('have.attr', 'data-active', 'true');
94+
cy.wrap(RENDER_COUNTS).should('deep.equal', { 1: 3, 2: 2, 3: 1, 4: 1 });
95+
96+
// Click 3: 2 → 3. Item 2 flips (true → false), item 3 flips (false → true).
97+
// Item 1's alternate fiber retains lanes from click 2. Under the legacy
98+
// `useState` path the in-render reducer is invoked, bails out, and the
99+
// JSX is discarded — but the function body already incremented the
100+
// counter. This assertion pins item 1 to 3 renders (not 4).
101+
cy.get('[data-testid=provider]').click();
102+
cy.get('[data-testid=item-2]').should('have.attr', 'data-active', 'false');
103+
cy.get('[data-testid=item-3]').should('have.attr', 'data-active', 'true');
104+
cy.wrap(RENDER_COUNTS).should('deep.equal', { 1: 3, 2: 3, 3: 2, 4: 1 });
105+
});
106+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"isolatedModules": false,
5+
"types": ["node", "cypress", "cypress-real-events"],
6+
"typeRoots": ["../../../node_modules", "../../../node_modules/@types"],
7+
"lib": ["ES2019", "dom"]
8+
},
9+
"include": ["**/*.cy.ts", "**/*.cy.tsx"]
10+
}

packages/react-components/react-context-selector/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
},
1818
{
1919
"path": "./tsconfig.spec.json"
20+
},
21+
{
22+
"path": "./tsconfig.cy.json"
2023
}
2124
]
2225
}

packages/react-components/react-context-selector/tsconfig.lib.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"inlineSources": true,
1010
"types": ["static-assets", "environment"]
1111
},
12-
"exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx"],
12+
"exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.cy.ts", "**/*.cy.tsx"],
1313
"include": ["./src/**/*.ts", "./src/**/*.tsx"]
1414
}

0 commit comments

Comments
 (0)