Merged
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
32 changes: 22 additions & 10 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,7 @@ import {
FormReset,
} from './ReactFiberFlags';
import {
NoFlags as HookNoFlags,
HasEffect as HookHasEffect,
Layout as HookLayout,
Passive as HookPassive,
Expand DownExpand Up@@ -1774,21 +1775,31 @@ function updateSyncExternalStore<T>(
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
const storeChanged =
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
(workInProgressHook.memoizedState.tag & HookHasEffect) !== HookNoFlags);

// Even if nothing changed during this render, we push the effect so it is
// always in the effect list. That way it re-runs whenever the passive
// effects are reconnected, like when a hidden Activity tree is shown again.
// While the tree was hidden we were not subscribed to the store, so
// mutations during that window notified nobody, and if the reveal didn't
// re-render this component (or rendered before the mutation), nothing
// would ever detect them. When nothing changed, the effect is pushed
// without the HasEffect tag so a regular commit skips it.
pushSimpleEffect(
storeChanged ? HookHasEffect | HookPassive : HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

if (storeChanged) {
fiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
Expand DownExpand Up@@ -1848,7 +1859,8 @@ function updateStoreInstance<T>(
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapsho and getSnapshot values to bail out. We need to check one more time.
// snapshot and getSnapshot values to bail out. We need to check one more
// time. This effect also re-runs when a hidden Activity tree is revealed.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
// We intentionally don't log update times and stacks here because this
Expand Down
126 changes: 126 additions & 0 deletions packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,132 @@ describe('useSyncExternalStore', () => {
},
);

// Regression test for https://github.com/facebook/react/issues/27670
it('detects store mutations from a layout effect while an Activity subtree is being revealed', async () => {
const store = createExternalStore('revision:1');

function App({mode, revision}) {
return (
<React.Activity mode={mode}>
<Wrapper revision={revision}>
<Subscriber />
</Wrapper>
</React.Activity>
);
}

function Wrapper({children, revision}) {
useLayoutEffect(() => {
store.set('revision:' + revision);
}, [revision]);

return (
<>
wrapper:{revision}
{', '}
{children}
</>
);
}

function Subscriber() {
const revision = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={revision} />;
}

const root = ReactNoop.createRoot();

// Mount the app
await act(() => {
root.render(<App mode="visible" revision="1" />);
});
assertLog(['revision:1']);
expect(root).toMatchRenderedOutput('wrapper:1, revision:1');
expect(store.getSubscriberCount()).toBe(1);

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" revision="1" />);
});
assertLog(['revision:1']);
expect(store.getSubscriberCount()).toBe(0);

// Show the subtree again. A layout effect mutates the store during the
// reveal, after the Subscriber rendered but before it resubscribed. When
// it resubscribes, it must detect the mutation it missed.
await act(() => {
root.render(<App mode="visible" revision="2" />);
});
assertLog(['revision:1', 'revision:2']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('wrapper:2, revision:2');
});

// Regression test for https://github.com/facebook/react/issues/27670
it(
'detects store mutations that happened while an Activity subtree was ' +
'hidden, even if the subtree bails out of rendering when revealed',
async () => {
const store = createExternalStore('initial');

// Memoized so that revealing the Activity boundary doesn't re-render
// the subscriber. This matches components memoized by React.memo or
// React Compiler.
const Subscriber = React.memo(({label}) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={label + ':' + value} />;
});

function App({mode, label}) {
return (
<React.Activity mode={mode}>
<Subscriber label={label} />
</React.Activity>
);
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App mode="visible" label="a" />);
});
assertLog(['a:initial']);
expect(root).toMatchRenderedOutput('a:initial');
expect(store.getSubscriberCount()).toBe(1);

// Re-render the subscriber once with different props, with no store
// change. This replaces its effect list with one that contains only
// the subscription effect, no interleaved mutation check.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:initial']);
expect(root).toMatchRenderedOutput('b:initial');

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" label="b" />);
});
expect(store.getSubscriberCount()).toBe(0);

// Mutate the store while the subtree is hidden. Nothing is subscribed,
// so no update is scheduled.
await act(() => {
store.set('updated');
});
assertLog([]);

// Show the subtree again. The memoized component bails out of
// rendering, so resubscribing to the store is the only chance to
// detect the mutation that happened while it was hidden.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:updated']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('b:updated');
},
);

it('regression: does not infinite loop for only changing store reference in render', async () => {
let store = {value: {}};
let listeners = [];
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
32 changes: 22 additions & 10 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,7 @@ import {
FormReset,
} from './ReactFiberFlags';
import {
NoFlags as HookNoFlags,
HasEffect as HookHasEffect,
Layout as HookLayout,
Passive as HookPassive,
Expand DownExpand Up@@ -1774,21 +1775,31 @@ function updateSyncExternalStore<T>(
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
const storeChanged =
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
(workInProgressHook.memoizedState.tag & HookHasEffect) !== HookNoFlags);

// Even if nothing changed during this render, we push the effect so it is
// always in the effect list. That way it re-runs whenever the passive
// effects are reconnected, like when a hidden Activity tree is shown again.
// While the tree was hidden we were not subscribed to the store, so
// mutations during that window notified nobody, and if the reveal didn't
// re-render this component (or rendered before the mutation), nothing
// would ever detect them. When nothing changed, the effect is pushed
// without the HasEffect tag so a regular commit skips it.
pushSimpleEffect(
storeChanged ? HookHasEffect | HookPassive : HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

if (storeChanged) {
fiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
Expand DownExpand Up@@ -1848,7 +1859,8 @@ function updateStoreInstance<T>(
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapsho and getSnapshot values to bail out. We need to check one more time.
// snapshot and getSnapshot values to bail out. We need to check one more
// time. This effect also re-runs when a hidden Activity tree is revealed.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
// We intentionally don't log update times and stacks here because this
Expand Down
126 changes: 126 additions & 0 deletions packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,132 @@ describe('useSyncExternalStore', () => {
},
);

// Regression test for https://github.com/facebook/react/issues/27670
it('detects store mutations from a layout effect while an Activity subtree is being revealed', async () => {
const store = createExternalStore('revision:1');

function App({mode, revision}) {
return (
<React.Activity mode={mode}>
<Wrapper revision={revision}>
<Subscriber />
</Wrapper>
</React.Activity>
);
}

function Wrapper({children, revision}) {
useLayoutEffect(() => {
store.set('revision:' + revision);
}, [revision]);

return (
<>
wrapper:{revision}
{', '}
{children}
</>
);
}

function Subscriber() {
const revision = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={revision} />;
}

const root = ReactNoop.createRoot();

// Mount the app
await act(() => {
root.render(<App mode="visible" revision="1" />);
});
assertLog(['revision:1']);
expect(root).toMatchRenderedOutput('wrapper:1, revision:1');
expect(store.getSubscriberCount()).toBe(1);

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" revision="1" />);
});
assertLog(['revision:1']);
expect(store.getSubscriberCount()).toBe(0);

// Show the subtree again. A layout effect mutates the store during the
// reveal, after the Subscriber rendered but before it resubscribed. When
// it resubscribes, it must detect the mutation it missed.
await act(() => {
root.render(<App mode="visible" revision="2" />);
});
assertLog(['revision:1', 'revision:2']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('wrapper:2, revision:2');
});

// Regression test for https://github.com/facebook/react/issues/27670
it(
'detects store mutations that happened while an Activity subtree was ' +
'hidden, even if the subtree bails out of rendering when revealed',
async () => {
const store = createExternalStore('initial');

// Memoized so that revealing the Activity boundary doesn't re-render
// the subscriber. This matches components memoized by React.memo or
// React Compiler.
const Subscriber = React.memo(({label}) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={label + ':' + value} />;
});

function App({mode, label}) {
return (
<React.Activity mode={mode}>
<Subscriber label={label} />
</React.Activity>
);
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App mode="visible" label="a" />);
});
assertLog(['a:initial']);
expect(root).toMatchRenderedOutput('a:initial');
expect(store.getSubscriberCount()).toBe(1);

// Re-render the subscriber once with different props, with no store
// change. This replaces its effect list with one that contains only
// the subscription effect, no interleaved mutation check.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:initial']);
expect(root).toMatchRenderedOutput('b:initial');

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" label="b" />);
});
expect(store.getSubscriberCount()).toBe(0);

// Mutate the store while the subtree is hidden. Nothing is subscribed,
// so no update is scheduled.
await act(() => {
store.set('updated');
});
assertLog([]);

// Show the subtree again. The memoized component bails out of
// rendering, so resubscribing to the store is the only chance to
// detect the mutation that happened while it was hidden.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:updated']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('b:updated');
},
);

it('regression: does not infinite loop for only changing store reference in render', async () => {
let store = {value: {}};
let listeners = [];
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
32 changes: 22 additions & 10 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,7 @@ import {
FormReset,
} from './ReactFiberFlags';
import {
NoFlags as HookNoFlags,
HasEffect as HookHasEffect,
Layout as HookLayout,
Passive as HookPassive,
Expand DownExpand Up@@ -1774,21 +1775,31 @@ function updateSyncExternalStore<T>(
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
const storeChanged =
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
(workInProgressHook.memoizedState.tag & HookHasEffect) !== HookNoFlags);

// Even if nothing changed during this render, we push the effect so it is
// always in the effect list. That way it re-runs whenever the passive
// effects are reconnected, like when a hidden Activity tree is shown again.
// While the tree was hidden we were not subscribed to the store, so
// mutations during that window notified nobody, and if the reveal didn't
// re-render this component (or rendered before the mutation), nothing
// would ever detect them. When nothing changed, the effect is pushed
// without the HasEffect tag so a regular commit skips it.
pushSimpleEffect(
storeChanged ? HookHasEffect | HookPassive : HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

if (storeChanged) {
fiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
Expand DownExpand Up@@ -1848,7 +1859,8 @@ function updateStoreInstance<T>(
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapsho and getSnapshot values to bail out. We need to check one more time.
// snapshot and getSnapshot values to bail out. We need to check one more
// time. This effect also re-runs when a hidden Activity tree is revealed.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
// We intentionally don't log update times and stacks here because this
Expand Down
126 changes: 126 additions & 0 deletions packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,132 @@ describe('useSyncExternalStore', () => {
},
);

// Regression test for https://github.com/facebook/react/issues/27670
it('detects store mutations from a layout effect while an Activity subtree is being revealed', async () => {
const store = createExternalStore('revision:1');

function App({mode, revision}) {
return (
<React.Activity mode={mode}>
<Wrapper revision={revision}>
<Subscriber />
</Wrapper>
</React.Activity>
);
}

function Wrapper({children, revision}) {
useLayoutEffect(() => {
store.set('revision:' + revision);
}, [revision]);

return (
<>
wrapper:{revision}
{', '}
{children}
</>
);
}

function Subscriber() {
const revision = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={revision} />;
}

const root = ReactNoop.createRoot();

// Mount the app
await act(() => {
root.render(<App mode="visible" revision="1" />);
});
assertLog(['revision:1']);
expect(root).toMatchRenderedOutput('wrapper:1, revision:1');
expect(store.getSubscriberCount()).toBe(1);

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" revision="1" />);
});
assertLog(['revision:1']);
expect(store.getSubscriberCount()).toBe(0);

// Show the subtree again. A layout effect mutates the store during the
// reveal, after the Subscriber rendered but before it resubscribed. When
// it resubscribes, it must detect the mutation it missed.
await act(() => {
root.render(<App mode="visible" revision="2" />);
});
assertLog(['revision:1', 'revision:2']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('wrapper:2, revision:2');
});

// Regression test for https://github.com/facebook/react/issues/27670
it(
'detects store mutations that happened while an Activity subtree was ' +
'hidden, even if the subtree bails out of rendering when revealed',
async () => {
const store = createExternalStore('initial');

// Memoized so that revealing the Activity boundary doesn't re-render
// the subscriber. This matches components memoized by React.memo or
// React Compiler.
const Subscriber = React.memo(({label}) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={label + ':' + value} />;
});

function App({mode, label}) {
return (
<React.Activity mode={mode}>
<Subscriber label={label} />
</React.Activity>
);
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App mode="visible" label="a" />);
});
assertLog(['a:initial']);
expect(root).toMatchRenderedOutput('a:initial');
expect(store.getSubscriberCount()).toBe(1);

// Re-render the subscriber once with different props, with no store
// change. This replaces its effect list with one that contains only
// the subscription effect, no interleaved mutation check.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:initial']);
expect(root).toMatchRenderedOutput('b:initial');

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" label="b" />);
});
expect(store.getSubscriberCount()).toBe(0);

// Mutate the store while the subtree is hidden. Nothing is subscribed,
// so no update is scheduled.
await act(() => {
store.set('updated');
});
assertLog([]);

// Show the subtree again. The memoized component bails out of
// rendering, so resubscribing to the store is the only chance to
// detect the mutation that happened while it was hidden.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:updated']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('b:updated');
},
);

it('regression: does not infinite loop for only changing store reference in render', async () => {
let store = {value: {}};
let listeners = [];
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
32 changes: 22 additions & 10 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,7 @@ import {
FormReset,
} from './ReactFiberFlags';
import {
NoFlags as HookNoFlags,
HasEffect as HookHasEffect,
Layout as HookLayout,
Passive as HookPassive,
Expand DownExpand Up@@ -1774,21 +1775,31 @@ function updateSyncExternalStore<T>(
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
const storeChanged =
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
(workInProgressHook.memoizedState.tag & HookHasEffect) !== HookNoFlags);

// Even if nothing changed during this render, we push the effect so it is
// always in the effect list. That way it re-runs whenever the passive
// effects are reconnected, like when a hidden Activity tree is shown again.
// While the tree was hidden we were not subscribed to the store, so
// mutations during that window notified nobody, and if the reveal didn't
// re-render this component (or rendered before the mutation), nothing
// would ever detect them. When nothing changed, the effect is pushed
// without the HasEffect tag so a regular commit skips it.
pushSimpleEffect(
storeChanged ? HookHasEffect | HookPassive : HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

if (storeChanged) {
fiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
Expand DownExpand Up@@ -1848,7 +1859,8 @@ function updateStoreInstance<T>(
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapsho and getSnapshot values to bail out. We need to check one more time.
// snapshot and getSnapshot values to bail out. We need to check one more
// time. This effect also re-runs when a hidden Activity tree is revealed.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
// We intentionally don't log update times and stacks here because this
Expand Down
126 changes: 126 additions & 0 deletions packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,132 @@ describe('useSyncExternalStore', () => {
},
);

// Regression test for https://github.com/facebook/react/issues/27670
it('detects store mutations from a layout effect while an Activity subtree is being revealed', async () => {
const store = createExternalStore('revision:1');

function App({mode, revision}) {
return (
<React.Activity mode={mode}>
<Wrapper revision={revision}>
<Subscriber />
</Wrapper>
</React.Activity>
);
}

function Wrapper({children, revision}) {
useLayoutEffect(() => {
store.set('revision:' + revision);
}, [revision]);

return (
<>
wrapper:{revision}
{', '}
{children}
</>
);
}

function Subscriber() {
const revision = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={revision} />;
}

const root = ReactNoop.createRoot();

// Mount the app
await act(() => {
root.render(<App mode="visible" revision="1" />);
});
assertLog(['revision:1']);
expect(root).toMatchRenderedOutput('wrapper:1, revision:1');
expect(store.getSubscriberCount()).toBe(1);

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" revision="1" />);
});
assertLog(['revision:1']);
expect(store.getSubscriberCount()).toBe(0);

// Show the subtree again. A layout effect mutates the store during the
// reveal, after the Subscriber rendered but before it resubscribed. When
// it resubscribes, it must detect the mutation it missed.
await act(() => {
root.render(<App mode="visible" revision="2" />);
});
assertLog(['revision:1', 'revision:2']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('wrapper:2, revision:2');
});

// Regression test for https://github.com/facebook/react/issues/27670
it(
'detects store mutations that happened while an Activity subtree was ' +
'hidden, even if the subtree bails out of rendering when revealed',
async () => {
const store = createExternalStore('initial');

// Memoized so that revealing the Activity boundary doesn't re-render
// the subscriber. This matches components memoized by React.memo or
// React Compiler.
const Subscriber = React.memo(({label}) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={label + ':' + value} />;
});

function App({mode, label}) {
return (
<React.Activity mode={mode}>
<Subscriber label={label} />
</React.Activity>
);
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App mode="visible" label="a" />);
});
assertLog(['a:initial']);
expect(root).toMatchRenderedOutput('a:initial');
expect(store.getSubscriberCount()).toBe(1);

// Re-render the subscriber once with different props, with no store
// change. This replaces its effect list with one that contains only
// the subscription effect, no interleaved mutation check.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:initial']);
expect(root).toMatchRenderedOutput('b:initial');

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" label="b" />);
});
expect(store.getSubscriberCount()).toBe(0);

// Mutate the store while the subtree is hidden. Nothing is subscribed,
// so no update is scheduled.
await act(() => {
store.set('updated');
});
assertLog([]);

// Show the subtree again. The memoized component bails out of
// rendering, so resubscribing to the store is the only chance to
// detect the mutation that happened while it was hidden.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:updated']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('b:updated');
},
);

it('regression: does not infinite loop for only changing store reference in render', async () => {
let store = {value: {}};
let listeners = [];
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
32 changes: 22 additions & 10 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,7 @@ import {
FormReset,
} from './ReactFiberFlags';
import {
NoFlags as HookNoFlags,
HasEffect as HookHasEffect,
Layout as HookLayout,
Passive as HookPassive,
Expand DownExpand Up@@ -1774,21 +1775,31 @@ function updateSyncExternalStore<T>(
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
const storeChanged =
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
(workInProgressHook.memoizedState.tag & HookHasEffect) !== HookNoFlags);

// Even if nothing changed during this render, we push the effect so it is
// always in the effect list. That way it re-runs whenever the passive
// effects are reconnected, like when a hidden Activity tree is shown again.
// While the tree was hidden we were not subscribed to the store, so
// mutations during that window notified nobody, and if the reveal didn't
// re-render this component (or rendered before the mutation), nothing
// would ever detect them. When nothing changed, the effect is pushed
// without the HasEffect tag so a regular commit skips it.
pushSimpleEffect(
storeChanged ? HookHasEffect | HookPassive : HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

if (storeChanged) {
fiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
Expand DownExpand Up@@ -1848,7 +1859,8 @@ function updateStoreInstance<T>(
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapsho and getSnapshot values to bail out. We need to check one more time.
// snapshot and getSnapshot values to bail out. We need to check one more
// time. This effect also re-runs when a hidden Activity tree is revealed.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
// We intentionally don't log update times and stacks here because this
Expand Down
126 changes: 126 additions & 0 deletions packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,132 @@ describe('useSyncExternalStore', () => {
},
);

// Regression test for https://github.com/facebook/react/issues/27670
it('detects store mutations from a layout effect while an Activity subtree is being revealed', async () => {
const store = createExternalStore('revision:1');

function App({mode, revision}) {
return (
<React.Activity mode={mode}>
<Wrapper revision={revision}>
<Subscriber />
</Wrapper>
</React.Activity>
);
}

function Wrapper({children, revision}) {
useLayoutEffect(() => {
store.set('revision:' + revision);
}, [revision]);

return (
<>
wrapper:{revision}
{', '}
{children}
</>
);
}

function Subscriber() {
const revision = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={revision} />;
}

const root = ReactNoop.createRoot();

// Mount the app
await act(() => {
root.render(<App mode="visible" revision="1" />);
});
assertLog(['revision:1']);
expect(root).toMatchRenderedOutput('wrapper:1, revision:1');
expect(store.getSubscriberCount()).toBe(1);

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" revision="1" />);
});
assertLog(['revision:1']);
expect(store.getSubscriberCount()).toBe(0);

// Show the subtree again. A layout effect mutates the store during the
// reveal, after the Subscriber rendered but before it resubscribed. When
// it resubscribes, it must detect the mutation it missed.
await act(() => {
root.render(<App mode="visible" revision="2" />);
});
assertLog(['revision:1', 'revision:2']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('wrapper:2, revision:2');
});

// Regression test for https://github.com/facebook/react/issues/27670
it(
'detects store mutations that happened while an Activity subtree was ' +
'hidden, even if the subtree bails out of rendering when revealed',
async () => {
const store = createExternalStore('initial');

// Memoized so that revealing the Activity boundary doesn't re-render
// the subscriber. This matches components memoized by React.memo or
// React Compiler.
const Subscriber = React.memo(({label}) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={label + ':' + value} />;
});

function App({mode, label}) {
return (
<React.Activity mode={mode}>
<Subscriber label={label} />
</React.Activity>
);
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App mode="visible" label="a" />);
});
assertLog(['a:initial']);
expect(root).toMatchRenderedOutput('a:initial');
expect(store.getSubscriberCount()).toBe(1);

// Re-render the subscriber once with different props, with no store
// change. This replaces its effect list with one that contains only
// the subscription effect, no interleaved mutation check.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:initial']);
expect(root).toMatchRenderedOutput('b:initial');

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" label="b" />);
});
expect(store.getSubscriberCount()).toBe(0);

// Mutate the store while the subtree is hidden. Nothing is subscribed,
// so no update is scheduled.
await act(() => {
store.set('updated');
});
assertLog([]);

// Show the subtree again. The memoized component bails out of
// rendering, so resubscribing to the store is the only chance to
// detect the mutation that happened while it was hidden.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:updated']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('b:updated');
},
);

it('regression: does not infinite loop for only changing store reference in render', async () => {
let store = {value: {}};
let listeners = [];
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
32 changes: 22 additions & 10 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,7 @@ import {
FormReset,
} from './ReactFiberFlags';
import {
NoFlags as HookNoFlags,
HasEffect as HookHasEffect,
Layout as HookLayout,
Passive as HookPassive,
Expand DownExpand Up@@ -1774,21 +1775,31 @@ function updateSyncExternalStore<T>(
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
const storeChanged =
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
(workInProgressHook.memoizedState.tag & HookHasEffect) !== HookNoFlags);

// Even if nothing changed during this render, we push the effect so it is
// always in the effect list. That way it re-runs whenever the passive
// effects are reconnected, like when a hidden Activity tree is shown again.
// While the tree was hidden we were not subscribed to the store, so
// mutations during that window notified nobody, and if the reveal didn't
// re-render this component (or rendered before the mutation), nothing
// would ever detect them. When nothing changed, the effect is pushed
// without the HasEffect tag so a regular commit skips it.
pushSimpleEffect(
storeChanged ? HookHasEffect | HookPassive : HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

if (storeChanged) {
fiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
Expand DownExpand Up@@ -1848,7 +1859,8 @@ function updateStoreInstance<T>(
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapsho and getSnapshot values to bail out. We need to check one more time.
// snapshot and getSnapshot values to bail out. We need to check one more
// time. This effect also re-runs when a hidden Activity tree is revealed.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
// We intentionally don't log update times and stacks here because this
Expand Down
126 changes: 126 additions & 0 deletions packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,132 @@ describe('useSyncExternalStore', () => {
},
);

// Regression test for https://github.com/facebook/react/issues/27670
it('detects store mutations from a layout effect while an Activity subtree is being revealed', async () => {
const store = createExternalStore('revision:1');

function App({mode, revision}) {
return (
<React.Activity mode={mode}>
<Wrapper revision={revision}>
<Subscriber />
</Wrapper>
</React.Activity>
);
}

function Wrapper({children, revision}) {
useLayoutEffect(() => {
store.set('revision:' + revision);
}, [revision]);

return (
<>
wrapper:{revision}
{', '}
{children}
</>
);
}

function Subscriber() {
const revision = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={revision} />;
}

const root = ReactNoop.createRoot();

// Mount the app
await act(() => {
root.render(<App mode="visible" revision="1" />);
});
assertLog(['revision:1']);
expect(root).toMatchRenderedOutput('wrapper:1, revision:1');
expect(store.getSubscriberCount()).toBe(1);

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" revision="1" />);
});
assertLog(['revision:1']);
expect(store.getSubscriberCount()).toBe(0);

// Show the subtree again. A layout effect mutates the store during the
// reveal, after the Subscriber rendered but before it resubscribed. When
// it resubscribes, it must detect the mutation it missed.
await act(() => {
root.render(<App mode="visible" revision="2" />);
});
assertLog(['revision:1', 'revision:2']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('wrapper:2, revision:2');
});

// Regression test for https://github.com/facebook/react/issues/27670
it(
'detects store mutations that happened while an Activity subtree was ' +
'hidden, even if the subtree bails out of rendering when revealed',
async () => {
const store = createExternalStore('initial');

// Memoized so that revealing the Activity boundary doesn't re-render
// the subscriber. This matches components memoized by React.memo or
// React Compiler.
const Subscriber = React.memo(({label}) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={label + ':' + value} />;
});

function App({mode, label}) {
return (
<React.Activity mode={mode}>
<Subscriber label={label} />
</React.Activity>
);
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App mode="visible" label="a" />);
});
assertLog(['a:initial']);
expect(root).toMatchRenderedOutput('a:initial');
expect(store.getSubscriberCount()).toBe(1);

// Re-render the subscriber once with different props, with no store
// change. This replaces its effect list with one that contains only
// the subscription effect, no interleaved mutation check.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:initial']);
expect(root).toMatchRenderedOutput('b:initial');

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" label="b" />);
});
expect(store.getSubscriberCount()).toBe(0);

// Mutate the store while the subtree is hidden. Nothing is subscribed,
// so no update is scheduled.
await act(() => {
store.set('updated');
});
assertLog([]);

// Show the subtree again. The memoized component bails out of
// rendering, so resubscribing to the store is the only chance to
// detect the mutation that happened while it was hidden.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:updated']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('b:updated');
},
);

it('regression: does not infinite loop for only changing store reference in render', async () => {
let store = {value: {}};
let listeners = [];
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
32 changes: 22 additions & 10 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,7 @@ import {
FormReset,
} from './ReactFiberFlags';
import {
NoFlags as HookNoFlags,
HasEffect as HookHasEffect,
Layout as HookLayout,
Passive as HookPassive,
Expand DownExpand Up@@ -1774,21 +1775,31 @@ function updateSyncExternalStore<T>(
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
const storeChanged =
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
(workInProgressHook.memoizedState.tag & HookHasEffect) !== HookNoFlags);

// Even if nothing changed during this render, we push the effect so it is
// always in the effect list. That way it re-runs whenever the passive
// effects are reconnected, like when a hidden Activity tree is shown again.
// While the tree was hidden we were not subscribed to the store, so
// mutations during that window notified nobody, and if the reveal didn't
// re-render this component (or rendered before the mutation), nothing
// would ever detect them. When nothing changed, the effect is pushed
// without the HasEffect tag so a regular commit skips it.
pushSimpleEffect(
storeChanged ? HookHasEffect | HookPassive : HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

if (storeChanged) {
fiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
Expand DownExpand Up@@ -1848,7 +1859,8 @@ function updateStoreInstance<T>(
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapsho and getSnapshot values to bail out. We need to check one more time.
// snapshot and getSnapshot values to bail out. We need to check one more
// time. This effect also re-runs when a hidden Activity tree is revealed.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
// We intentionally don't log update times and stacks here because this
Expand Down
126 changes: 126 additions & 0 deletions packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,132 @@ describe('useSyncExternalStore', () => {
},
);

// Regression test for https://github.com/facebook/react/issues/27670
it('detects store mutations from a layout effect while an Activity subtree is being revealed', async () => {
const store = createExternalStore('revision:1');

function App({mode, revision}) {
return (
<React.Activity mode={mode}>
<Wrapper revision={revision}>
<Subscriber />
</Wrapper>
</React.Activity>
);
}

function Wrapper({children, revision}) {
useLayoutEffect(() => {
store.set('revision:' + revision);
}, [revision]);

return (
<>
wrapper:{revision}
{', '}
{children}
</>
);
}

function Subscriber() {
const revision = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={revision} />;
}

const root = ReactNoop.createRoot();

// Mount the app
await act(() => {
root.render(<App mode="visible" revision="1" />);
});
assertLog(['revision:1']);
expect(root).toMatchRenderedOutput('wrapper:1, revision:1');
expect(store.getSubscriberCount()).toBe(1);

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" revision="1" />);
});
assertLog(['revision:1']);
expect(store.getSubscriberCount()).toBe(0);

// Show the subtree again. A layout effect mutates the store during the
// reveal, after the Subscriber rendered but before it resubscribed. When
// it resubscribes, it must detect the mutation it missed.
await act(() => {
root.render(<App mode="visible" revision="2" />);
});
assertLog(['revision:1', 'revision:2']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('wrapper:2, revision:2');
});

// Regression test for https://github.com/facebook/react/issues/27670
it(
'detects store mutations that happened while an Activity subtree was ' +
'hidden, even if the subtree bails out of rendering when revealed',
async () => {
const store = createExternalStore('initial');

// Memoized so that revealing the Activity boundary doesn't re-render
// the subscriber. This matches components memoized by React.memo or
// React Compiler.
const Subscriber = React.memo(({label}) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={label + ':' + value} />;
});

function App({mode, label}) {
return (
<React.Activity mode={mode}>
<Subscriber label={label} />
</React.Activity>
);
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App mode="visible" label="a" />);
});
assertLog(['a:initial']);
expect(root).toMatchRenderedOutput('a:initial');
expect(store.getSubscriberCount()).toBe(1);

// Re-render the subscriber once with different props, with no store
// change. This replaces its effect list with one that contains only
// the subscription effect, no interleaved mutation check.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:initial']);
expect(root).toMatchRenderedOutput('b:initial');

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" label="b" />);
});
expect(store.getSubscriberCount()).toBe(0);

// Mutate the store while the subtree is hidden. Nothing is subscribed,
// so no update is scheduled.
await act(() => {
store.set('updated');
});
assertLog([]);

// Show the subtree again. The memoized component bails out of
// rendering, so resubscribing to the store is the only chance to
// detect the mutation that happened while it was hidden.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:updated']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('b:updated');
},
);

it('regression: does not infinite loop for only changing store reference in render', async () => {
let store = {value: {}};
let listeners = [];
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
32 changes: 22 additions & 10 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,7 @@ import {
FormReset,
} from './ReactFiberFlags';
import {
NoFlags as HookNoFlags,
HasEffect as HookHasEffect,
Layout as HookLayout,
Passive as HookPassive,
Expand DownExpand Up@@ -1774,21 +1775,31 @@ function updateSyncExternalStore<T>(
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
const storeChanged =
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
(workInProgressHook.memoizedState.tag & HookHasEffect) !== HookNoFlags);

// Even if nothing changed during this render, we push the effect so it is
// always in the effect list. That way it re-runs whenever the passive
// effects are reconnected, like when a hidden Activity tree is shown again.
// While the tree was hidden we were not subscribed to the store, so
// mutations during that window notified nobody, and if the reveal didn't
// re-render this component (or rendered before the mutation), nothing
// would ever detect them. When nothing changed, the effect is pushed
// without the HasEffect tag so a regular commit skips it.
pushSimpleEffect(
storeChanged ? HookHasEffect | HookPassive : HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

if (storeChanged) {
fiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
null,
);

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
Expand DownExpand Up@@ -1848,7 +1859,8 @@ function updateStoreInstance<T>(
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapsho and getSnapshot values to bail out. We need to check one more time.
// snapshot and getSnapshot values to bail out. We need to check one more
// time. This effect also re-runs when a hidden Activity tree is revealed.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
// We intentionally don't log update times and stacks here because this
Expand Down
126 changes: 126 additions & 0 deletions packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,6 +352,132 @@ describe('useSyncExternalStore', () => {
},
);

// Regression test for https://github.com/facebook/react/issues/27670
it('detects store mutations from a layout effect while an Activity subtree is being revealed', async () => {
const store = createExternalStore('revision:1');

function App({mode, revision}) {
return (
<React.Activity mode={mode}>
<Wrapper revision={revision}>
<Subscriber />
</Wrapper>
</React.Activity>
);
}

function Wrapper({children, revision}) {
useLayoutEffect(() => {
store.set('revision:' + revision);
}, [revision]);

return (
<>
wrapper:{revision}
{', '}
{children}
</>
);
}

function Subscriber() {
const revision = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={revision} />;
}

const root = ReactNoop.createRoot();

// Mount the app
await act(() => {
root.render(<App mode="visible" revision="1" />);
});
assertLog(['revision:1']);
expect(root).toMatchRenderedOutput('wrapper:1, revision:1');
expect(store.getSubscriberCount()).toBe(1);

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" revision="1" />);
});
assertLog(['revision:1']);
expect(store.getSubscriberCount()).toBe(0);

// Show the subtree again. A layout effect mutates the store during the
// reveal, after the Subscriber rendered but before it resubscribed. When
// it resubscribes, it must detect the mutation it missed.
await act(() => {
root.render(<App mode="visible" revision="2" />);
});
assertLog(['revision:1', 'revision:2']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('wrapper:2, revision:2');
});

// Regression test for https://github.com/facebook/react/issues/27670
it(
'detects store mutations that happened while an Activity subtree was ' +
'hidden, even if the subtree bails out of rendering when revealed',
async () => {
const store = createExternalStore('initial');

// Memoized so that revealing the Activity boundary doesn't re-render
// the subscriber. This matches components memoized by React.memo or
// React Compiler.
const Subscriber = React.memo(({label}) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={label + ':' + value} />;
});

function App({mode, label}) {
return (
<React.Activity mode={mode}>
<Subscriber label={label} />
</React.Activity>
);
}

const root = ReactNoop.createRoot();
await act(() => {
root.render(<App mode="visible" label="a" />);
});
assertLog(['a:initial']);
expect(root).toMatchRenderedOutput('a:initial');
expect(store.getSubscriberCount()).toBe(1);

// Re-render the subscriber once with different props, with no store
// change. This replaces its effect list with one that contains only
// the subscription effect, no interleaved mutation check.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:initial']);
expect(root).toMatchRenderedOutput('b:initial');

// Hide the subtree. React unsubscribes from the store.
await act(() => {
root.render(<App mode="hidden" label="b" />);
});
expect(store.getSubscriberCount()).toBe(0);

// Mutate the store while the subtree is hidden. Nothing is subscribed,
// so no update is scheduled.
await act(() => {
store.set('updated');
});
assertLog([]);

// Show the subtree again. The memoized component bails out of
// rendering, so resubscribing to the store is the only chance to
// detect the mutation that happened while it was hidden.
await act(() => {
root.render(<App mode="visible" label="b" />);
});
assertLog(['b:updated']);
expect(store.getSubscriberCount()).toBe(1);
expect(root).toMatchRenderedOutput('b:updated');
},
);

it('regression: does not infinite loop for only changing store reference in render', async () => {
let store = {value: {}};
let listeners = [];
Expand Down
Loading