Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -5588,6 +5588,14 @@ export function acquireResource(
props: any,
): null | Instance {
resource.count++;
// If a previously-created instance has been removed from the document
// (e.g. because it lived inside a portal container that was later
// removed from the DOM), we must treat it as if it were never created.
// Otherwise React would reuse the disconnected node and the styles it
// represents would be permanently lost for the rest of the session.
if (resource.instance !== null && !resource.instance.isConnected) {
resource.instance = null;
}
if (resource.instance === null) {
switch (resource.type) {
case 'style': {
Expand Down
66 changes: 66 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFloat-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8699,6 +8699,72 @@
</html>,
);
});

it('re-inserts a style resource when its portal container is removed from the DOM', async () => {
// Regression test for https://github.com/facebook/react/issues/36373:
// When a <style precedence> lives inside a portal container that gets
// removed from the DOM, React kept a stale reference to the detached
// node in the hoistable-styles cache. Any subsequent render of the same
// href silently skipped insertion, leaving styles permanently missing.
const container = document.createElement('div');

Check failure on line 8709 in packages/react-dom/src/__tests__/ReactDOMFloat-test.js

View workflow job for this annotation

GitHub Actions / Run eslint

'container' is already declared in the upper scope on line 29 column 5
document.body.appendChild(container);

// A portal container that we will later remove.
const portalTarget = document.createElement('div');
document.body.appendChild(portalTarget);

let setState;
function App() {
const [showPortal, setShow] = React.useState(true);
setState = setShow;
return (
<>
{showPortal &&
ReactDOM.createPortal(
<style href="x" precedence="custom">
{'.a{color:red}'}
</style>,
portalTarget,
)}
<style href="x" precedence="custom">
{'.a{color:red}'}
</style>
<div className="a">content</div>
</>
);
}

const root = ReactDOMClient.createRoot(container);
await clientAct(() => {
root.render(<App />);
});

// Both the portal render and the main-tree render share the same resource.
// After the first render the style should be in the document head.
const stylesBefore = document.head.querySelectorAll(
'[data-href="x"][data-precedence="custom"]',
);
expect(stylesBefore.length).toBe(1);

// Remove the portal target (simulates unmounting a detached portal container).
portalTarget.remove();

// Re-render to force a fresh acquireResource call with the same href.
await clientAct(() => {
setState(false); // hide portal, only main-tree style remains
});

// The style should still exist in the document — if the fix is absent,
// the cached-but-disconnected instance would be returned and the style
// would be gone from the document.
const stylesAfter = document.head.querySelectorAll(
'[data-href="x"][data-precedence="custom"]',
);
expect(stylesAfter.length).toBeGreaterThanOrEqual(1);

root.unmount();
container.remove();
});
});

describe('Script Resources', () => {
Expand Down
Loading