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
44 changes: 33 additions & 11 deletions packages/react-client/src/ReactFlightClient.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,7 @@ type BlockedChunk<T> = {
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
_debugChunk: null, // DEV-only
_debugInfo: ReactDebugInfo, // DEV-only
_receivedDebugInfo: null | Set<ReactDebugInfoEntry>, // DEV-only
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
Expand DownExpand Up@@ -276,6 +277,7 @@ function ReactPromise(status: any, value: any, reason: any) {
if (__DEV__) {
this._debugChunk = null;
this._debugInfo = [];
this._receivedDebugInfo = null;
}
}
// We subclass Promise.prototype so that we get other methods like .catch
Expand DownExpand Up@@ -1170,6 +1172,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
return;
}
}
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
cyclicChunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<T> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
Expand DownExpand Up@@ -1771,7 +1777,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
element.props = mappedValue;
Expand All@@ -1789,7 +1795,7 @@ function fulfillReference(
}
break;
default:
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
break;
Expand All@@ -1810,6 +1816,10 @@ function fulfillReference(
return;
}
const resolveListeners = chunk.value;
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
chunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<any> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = handler.value;
Expand DownExpand Up@@ -2148,25 +2158,37 @@ function resolveLazy(value: any): mixed {
}

function transferReferencedDebugInfo(
parentChunk: null | SomeChunk<any>,
receivingChunk: null | BlockedChunk<any>,
referencedChunk: SomeChunk<any>,
): void {
if (__DEV__) {
// We add the debug info to the initializing chunk since the resolution of
// that promise is also blocked by the referenced debug info. By adding it
// to both we can track it even if the array/element/lazy is extracted, or
// if the root is rendered as is.
if (parentChunk !== null) {
// We add the debug info to the receiving chunk since the resolution of that
// promise is also blocked by the referenced debug info. By adding it to
// both we can track it even if the array/element/lazy is extracted, or if
// the root is rendered as is.
if (receivingChunk !== null) {
const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
const receivingDebugInfo = receivingChunk._debugInfo;
// The receiving chunk takes each entry only once. A repeated entry
// carries no information. An entry repeats in two ways:
//
// - the receiving chunk references the same chunk more than once
// - two referenced chunks carry the same entry
//
// Without the set, the entries multiply along a chain of references.
let receivedDebugInfo = receivingChunk._receivedDebugInfo;
if (receivedDebugInfo === null) {
receivedDebugInfo = receivingChunk._receivedDebugInfo = new Set();
}
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
if (debugInfoEntry.name != null) {
debugInfoEntry as ReactComponentInfo;
// We're not transferring Component info since we use Component info
// in Debug info to fill in gaps between Fibers for the parent stack.
} else {
parentDebugInfo.push(debugInfoEntry);
} else if (!receivedDebugInfo.has(debugInfoEntry)) {
receivedDebugInfo.add(debugInfoEntry);
receivingDebugInfo.push(debugInfoEntry);
}
}
}
Expand Down
7 changes: 1 addition & 6 deletions packages/react-devtools-extensions/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,13 +97,8 @@ const build = async (tempPath, manifestPath, envExtension = {}) => {
);

const commit = getGitCommit();
const dateString = new Date().toLocaleDateString();
const manifest = JSON.parse(readFileSync(copiedManifestPath).toString());
const versionDateString = `${manifest.version} (${dateString})`;
if (manifest.version_name) {
manifest.version_name = versionDateString;
}
manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`;
manifest.description += `\n\nCreated from revision ${commit}.`;

if (process.env.NODE_ENV === 'development') {
// When building the local development version of the
Expand Down
10 changes: 9 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -457,7 +457,15 @@ export function createRenderState(
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
nonceScript === undefined
? importMapScriptStart
: stringToPrecomputedChunk(
'<script type="importmap" nonce="' +
escapeTextForBrowser(nonceScript) +
'">',
),
);
importMapChunks.push(
stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map))),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4520,6 +4520,40 @@ describe('ReactDOMFizzServer', () => {
);
});

it('applies the nonce option to the importmap script', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
nonce: 'R4nd0m',
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap" nonce="R4nd0m">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async="" nonce="R4nd0m"></script>'
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#_R_" blocking="render">'
: ''),
);
});

// bugfix: https://github.com/facebook/react/issues/27286
it('can render custom elements with children on ther server', async () => {
await act(() => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3441,6 +3441,70 @@ describe('ReactFlightDOMBrowser', () => {
);
});

it('should not exponentially accumulate debug info when deduplicated references are blocked', async () => {
// Regression test for debug info that grows exponentially, along the path
// that resolves a reference asynchronously. A form streams its field groups
// in parallel. Every group derives its descriptors from the group above it.
// Those descriptors deduplicate to the row of that group, which gives this
// row one reference per descriptor. Every descriptor names the client
// component of the field, and that chunk has not loaded, so every row
// blocks and the references wait for it. A row hands its debug info to the
// references that wait on it, so each of them copies the whole array and
// the count doubles at every group.
let loadFieldChunk;
const fieldChunkLoaded = new Promise(resolve => (loadFieldChunk = resolve));
const Field = clientExports(
function Field() {
return null;
},
'1',
'/field.js',
fieldChunkLoaded,
);

async function loadGroup(descriptors) {
return descriptors;
}

const groupCount = 10;
const groups = [];
let descriptors = [{name: 'a'}, {name: 'b'}];
for (let i = 0; i < groupCount; i++) {
descriptors = descriptors.map(descriptor => ({
parent: descriptor,
Field,
}));
groups.push(loadGroup(descriptors));
}

const stream = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream({groups}, webpackMap),
);

const response = ReactServerDOMClient.createFromReadableStream(stream);

// The root row holds only Promises, so it resolves while the field chunk is
// still loading. Subscribing to every group initializes its row, and a
// group finds the group above it blocked.
const form = await response;
const allGroups = Promise.all(form.groups);
loadFieldChunk();
const resolvedGroups = await allGroups;

expect(resolvedGroups).toHaveLength(groupCount);

if (__DEV__) {
// A group resolves to an array, so Flight hands the debug info of the row
// to that array, which is what DevTools reads. Every group contributes a
// fixed number of entries, so the last group holds a multiple of the
// group count. Without deduplication in transferReferencedDebugInfo the
// count doubles at every group.
expect(resolvedGroups[groupCount - 1]._debugInfo.length).toBeLessThan(
100,
);
}
});

describe('abort signal lifetime', () => {
// Collects the lifetime signal that React bounds each abort listener with.
// React passes that signal to addEventListener instead of calling
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4160,4 +4160,59 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});

it('should not exponentially accumulate debug info on deduplicated model chunks', async () => {
// Regression test for debug info that grows exponentially with the length
// of the chain, along the path that resolves a reference synchronously.
// Each page derives its records from the records of the page before it.
// Those records deduplicate to the row of that page, which gives this row
// one reference per record. That page has already resolved by then, and it
// still holds its debug info because it resolves to a plain object. Only an
// array, an async iterable, an element, or a lazy node hands the debug info
// to the value. So each reference copies the entries of the previous page,
// and the count doubles at every page.
const pageCount = 10;

async function loadPage(pageNumber, previousRecords) {
await delay(0);
const records = previousRecords.map(record => ({previous: record}));
return {
records,
nextPage:
pageNumber === pageCount ? null : loadPage(pageNumber + 1, records),
};
}

const stream = ReactServerDOMServer.renderToPipeableStream(
loadPage(1, [{id: 'a'}, {id: 'b'}]),
);

const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);

let page = await result;
let lastPage = null;
let pagesRead = 1;
while (page.nextPage !== null) {
lastPage = page.nextPage;
page = await page.nextPage;
pagesRead++;
}
expect(pagesRead).toBe(pageCount);

await finishLoadingStream(readable);

if (__DEV__) {
// Flight represents a Promise in the model with the chunk of its row, so
// this reads the debug info of the row itself. Every page contributes a
// fixed number of entries, so the last page holds a multiple of the page
// count. Without deduplication in transferReferencedDebugInfo the count
// doubles at every page.
expect(lastPage._debugInfo.length).toBeLessThan(100);
}
});
});
2 changes: 1 addition & 1 deletion packages/shared/ReactFeatureFlags.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
*/
exportconstenableInfiniteRenderLoopDetectionForceThrow: boolean=false;

exportconstenableConditionalUseWarning: boolean=__EXPERIMENTAL__;
exportconstenableConditionalUseWarning: boolean=true;

exportconstenableFragmentRefs: boolean=true;
exportconstenableFragmentRefsScrollIntoView: boolean=true;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@ export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__;
export const enableConditionalUseWarning: boolean = true;

export const enableEffectEventMutationPhase: boolean = true;

Expand Down
Loading
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
44 changes: 33 additions & 11 deletions packages/react-client/src/ReactFlightClient.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,7 @@ type BlockedChunk<T> = {
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
_debugChunk: null, // DEV-only
_debugInfo: ReactDebugInfo, // DEV-only
_receivedDebugInfo: null | Set<ReactDebugInfoEntry>, // DEV-only
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
Expand DownExpand Up@@ -276,6 +277,7 @@ function ReactPromise(status: any, value: any, reason: any) {
if (__DEV__) {
this._debugChunk = null;
this._debugInfo = [];
this._receivedDebugInfo = null;
}
}
// We subclass Promise.prototype so that we get other methods like .catch
Expand DownExpand Up@@ -1170,6 +1172,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
return;
}
}
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
cyclicChunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<T> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
Expand DownExpand Up@@ -1771,7 +1777,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
element.props = mappedValue;
Expand All@@ -1789,7 +1795,7 @@ function fulfillReference(
}
break;
default:
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
break;
Expand All@@ -1810,6 +1816,10 @@ function fulfillReference(
return;
}
const resolveListeners = chunk.value;
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
chunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<any> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = handler.value;
Expand DownExpand Up@@ -2148,25 +2158,37 @@ function resolveLazy(value: any): mixed {
}

function transferReferencedDebugInfo(
parentChunk: null | SomeChunk<any>,
receivingChunk: null | BlockedChunk<any>,
referencedChunk: SomeChunk<any>,
): void {
if (__DEV__) {
// We add the debug info to the initializing chunk since the resolution of
// that promise is also blocked by the referenced debug info. By adding it
// to both we can track it even if the array/element/lazy is extracted, or
// if the root is rendered as is.
if (parentChunk !== null) {
// We add the debug info to the receiving chunk since the resolution of that
// promise is also blocked by the referenced debug info. By adding it to
// both we can track it even if the array/element/lazy is extracted, or if
// the root is rendered as is.
if (receivingChunk !== null) {
const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
const receivingDebugInfo = receivingChunk._debugInfo;
// The receiving chunk takes each entry only once. A repeated entry
// carries no information. An entry repeats in two ways:
//
// - the receiving chunk references the same chunk more than once
// - two referenced chunks carry the same entry
//
// Without the set, the entries multiply along a chain of references.
let receivedDebugInfo = receivingChunk._receivedDebugInfo;
if (receivedDebugInfo === null) {
receivedDebugInfo = receivingChunk._receivedDebugInfo = new Set();
}
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
if (debugInfoEntry.name != null) {
debugInfoEntry as ReactComponentInfo;
// We're not transferring Component info since we use Component info
// in Debug info to fill in gaps between Fibers for the parent stack.
} else {
parentDebugInfo.push(debugInfoEntry);
} else if (!receivedDebugInfo.has(debugInfoEntry)) {
receivedDebugInfo.add(debugInfoEntry);
receivingDebugInfo.push(debugInfoEntry);
}
}
}
Expand Down
7 changes: 1 addition & 6 deletions packages/react-devtools-extensions/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,13 +97,8 @@ const build = async (tempPath, manifestPath, envExtension = {}) => {
);

const commit = getGitCommit();
const dateString = new Date().toLocaleDateString();
const manifest = JSON.parse(readFileSync(copiedManifestPath).toString());
const versionDateString = `${manifest.version} (${dateString})`;
if (manifest.version_name) {
manifest.version_name = versionDateString;
}
manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`;
manifest.description += `\n\nCreated from revision ${commit}.`;

if (process.env.NODE_ENV === 'development') {
// When building the local development version of the
Expand Down
10 changes: 9 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -457,7 +457,15 @@ export function createRenderState(
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
nonceScript === undefined
? importMapScriptStart
: stringToPrecomputedChunk(
'<script type="importmap" nonce="' +
escapeTextForBrowser(nonceScript) +
'">',
),
);
importMapChunks.push(
stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map))),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4520,6 +4520,40 @@ describe('ReactDOMFizzServer', () => {
);
});

it('applies the nonce option to the importmap script', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
nonce: 'R4nd0m',
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap" nonce="R4nd0m">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async="" nonce="R4nd0m"></script>'
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#_R_" blocking="render">'
: ''),
);
});

// bugfix: https://github.com/facebook/react/issues/27286
it('can render custom elements with children on ther server', async () => {
await act(() => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3441,6 +3441,70 @@ describe('ReactFlightDOMBrowser', () => {
);
});

it('should not exponentially accumulate debug info when deduplicated references are blocked', async () => {
// Regression test for debug info that grows exponentially, along the path
// that resolves a reference asynchronously. A form streams its field groups
// in parallel. Every group derives its descriptors from the group above it.
// Those descriptors deduplicate to the row of that group, which gives this
// row one reference per descriptor. Every descriptor names the client
// component of the field, and that chunk has not loaded, so every row
// blocks and the references wait for it. A row hands its debug info to the
// references that wait on it, so each of them copies the whole array and
// the count doubles at every group.
let loadFieldChunk;
const fieldChunkLoaded = new Promise(resolve => (loadFieldChunk = resolve));
const Field = clientExports(
function Field() {
return null;
},
'1',
'/field.js',
fieldChunkLoaded,
);

async function loadGroup(descriptors) {
return descriptors;
}

const groupCount = 10;
const groups = [];
let descriptors = [{name: 'a'}, {name: 'b'}];
for (let i = 0; i < groupCount; i++) {
descriptors = descriptors.map(descriptor => ({
parent: descriptor,
Field,
}));
groups.push(loadGroup(descriptors));
}

const stream = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream({groups}, webpackMap),
);

const response = ReactServerDOMClient.createFromReadableStream(stream);

// The root row holds only Promises, so it resolves while the field chunk is
// still loading. Subscribing to every group initializes its row, and a
// group finds the group above it blocked.
const form = await response;
const allGroups = Promise.all(form.groups);
loadFieldChunk();
const resolvedGroups = await allGroups;

expect(resolvedGroups).toHaveLength(groupCount);

if (__DEV__) {
// A group resolves to an array, so Flight hands the debug info of the row
// to that array, which is what DevTools reads. Every group contributes a
// fixed number of entries, so the last group holds a multiple of the
// group count. Without deduplication in transferReferencedDebugInfo the
// count doubles at every group.
expect(resolvedGroups[groupCount - 1]._debugInfo.length).toBeLessThan(
100,
);
}
});

describe('abort signal lifetime', () => {
// Collects the lifetime signal that React bounds each abort listener with.
// React passes that signal to addEventListener instead of calling
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4160,4 +4160,59 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});

it('should not exponentially accumulate debug info on deduplicated model chunks', async () => {
// Regression test for debug info that grows exponentially with the length
// of the chain, along the path that resolves a reference synchronously.
// Each page derives its records from the records of the page before it.
// Those records deduplicate to the row of that page, which gives this row
// one reference per record. That page has already resolved by then, and it
// still holds its debug info because it resolves to a plain object. Only an
// array, an async iterable, an element, or a lazy node hands the debug info
// to the value. So each reference copies the entries of the previous page,
// and the count doubles at every page.
const pageCount = 10;

async function loadPage(pageNumber, previousRecords) {
await delay(0);
const records = previousRecords.map(record => ({previous: record}));
return {
records,
nextPage:
pageNumber === pageCount ? null : loadPage(pageNumber + 1, records),
};
}

const stream = ReactServerDOMServer.renderToPipeableStream(
loadPage(1, [{id: 'a'}, {id: 'b'}]),
);

const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);

let page = await result;
let lastPage = null;
let pagesRead = 1;
while (page.nextPage !== null) {
lastPage = page.nextPage;
page = await page.nextPage;
pagesRead++;
}
expect(pagesRead).toBe(pageCount);

await finishLoadingStream(readable);

if (__DEV__) {
// Flight represents a Promise in the model with the chunk of its row, so
// this reads the debug info of the row itself. Every page contributes a
// fixed number of entries, so the last page holds a multiple of the page
// count. Without deduplication in transferReferencedDebugInfo the count
// doubles at every page.
expect(lastPage._debugInfo.length).toBeLessThan(100);
}
});
});
2 changes: 1 addition & 1 deletion packages/shared/ReactFeatureFlags.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
*/
exportconstenableInfiniteRenderLoopDetectionForceThrow: boolean=false;

exportconstenableConditionalUseWarning: boolean=__EXPERIMENTAL__;
exportconstenableConditionalUseWarning: boolean=true;

exportconstenableFragmentRefs: boolean=true;
exportconstenableFragmentRefsScrollIntoView: boolean=true;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@ export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__;
export const enableConditionalUseWarning: boolean = true;

export const enableEffectEventMutationPhase: boolean = true;

Expand Down
Loading
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
44 changes: 33 additions & 11 deletions packages/react-client/src/ReactFlightClient.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,7 @@ type BlockedChunk<T> = {
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
_debugChunk: null, // DEV-only
_debugInfo: ReactDebugInfo, // DEV-only
_receivedDebugInfo: null | Set<ReactDebugInfoEntry>, // DEV-only
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
Expand DownExpand Up@@ -276,6 +277,7 @@ function ReactPromise(status: any, value: any, reason: any) {
if (__DEV__) {
this._debugChunk = null;
this._debugInfo = [];
this._receivedDebugInfo = null;
}
}
// We subclass Promise.prototype so that we get other methods like .catch
Expand DownExpand Up@@ -1170,6 +1172,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
return;
}
}
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
cyclicChunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<T> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
Expand DownExpand Up@@ -1771,7 +1777,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
element.props = mappedValue;
Expand All@@ -1789,7 +1795,7 @@ function fulfillReference(
}
break;
default:
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
break;
Expand All@@ -1810,6 +1816,10 @@ function fulfillReference(
return;
}
const resolveListeners = chunk.value;
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
chunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<any> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = handler.value;
Expand DownExpand Up@@ -2148,25 +2158,37 @@ function resolveLazy(value: any): mixed {
}

function transferReferencedDebugInfo(
parentChunk: null | SomeChunk<any>,
receivingChunk: null | BlockedChunk<any>,
referencedChunk: SomeChunk<any>,
): void {
if (__DEV__) {
// We add the debug info to the initializing chunk since the resolution of
// that promise is also blocked by the referenced debug info. By adding it
// to both we can track it even if the array/element/lazy is extracted, or
// if the root is rendered as is.
if (parentChunk !== null) {
// We add the debug info to the receiving chunk since the resolution of that
// promise is also blocked by the referenced debug info. By adding it to
// both we can track it even if the array/element/lazy is extracted, or if
// the root is rendered as is.
if (receivingChunk !== null) {
const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
const receivingDebugInfo = receivingChunk._debugInfo;
// The receiving chunk takes each entry only once. A repeated entry
// carries no information. An entry repeats in two ways:
//
// - the receiving chunk references the same chunk more than once
// - two referenced chunks carry the same entry
//
// Without the set, the entries multiply along a chain of references.
let receivedDebugInfo = receivingChunk._receivedDebugInfo;
if (receivedDebugInfo === null) {
receivedDebugInfo = receivingChunk._receivedDebugInfo = new Set();
}
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
if (debugInfoEntry.name != null) {
debugInfoEntry as ReactComponentInfo;
// We're not transferring Component info since we use Component info
// in Debug info to fill in gaps between Fibers for the parent stack.
} else {
parentDebugInfo.push(debugInfoEntry);
} else if (!receivedDebugInfo.has(debugInfoEntry)) {
receivedDebugInfo.add(debugInfoEntry);
receivingDebugInfo.push(debugInfoEntry);
}
}
}
Expand Down
7 changes: 1 addition & 6 deletions packages/react-devtools-extensions/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,13 +97,8 @@ const build = async (tempPath, manifestPath, envExtension = {}) => {
);

const commit = getGitCommit();
const dateString = new Date().toLocaleDateString();
const manifest = JSON.parse(readFileSync(copiedManifestPath).toString());
const versionDateString = `${manifest.version} (${dateString})`;
if (manifest.version_name) {
manifest.version_name = versionDateString;
}
manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`;
manifest.description += `\n\nCreated from revision ${commit}.`;

if (process.env.NODE_ENV === 'development') {
// When building the local development version of the
Expand Down
10 changes: 9 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -457,7 +457,15 @@ export function createRenderState(
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
nonceScript === undefined
? importMapScriptStart
: stringToPrecomputedChunk(
'<script type="importmap" nonce="' +
escapeTextForBrowser(nonceScript) +
'">',
),
);
importMapChunks.push(
stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map))),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4520,6 +4520,40 @@ describe('ReactDOMFizzServer', () => {
);
});

it('applies the nonce option to the importmap script', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
nonce: 'R4nd0m',
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap" nonce="R4nd0m">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async="" nonce="R4nd0m"></script>'
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#_R_" blocking="render">'
: ''),
);
});

// bugfix: https://github.com/facebook/react/issues/27286
it('can render custom elements with children on ther server', async () => {
await act(() => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3441,6 +3441,70 @@ describe('ReactFlightDOMBrowser', () => {
);
});

it('should not exponentially accumulate debug info when deduplicated references are blocked', async () => {
// Regression test for debug info that grows exponentially, along the path
// that resolves a reference asynchronously. A form streams its field groups
// in parallel. Every group derives its descriptors from the group above it.
// Those descriptors deduplicate to the row of that group, which gives this
// row one reference per descriptor. Every descriptor names the client
// component of the field, and that chunk has not loaded, so every row
// blocks and the references wait for it. A row hands its debug info to the
// references that wait on it, so each of them copies the whole array and
// the count doubles at every group.
let loadFieldChunk;
const fieldChunkLoaded = new Promise(resolve => (loadFieldChunk = resolve));
const Field = clientExports(
function Field() {
return null;
},
'1',
'/field.js',
fieldChunkLoaded,
);

async function loadGroup(descriptors) {
return descriptors;
}

const groupCount = 10;
const groups = [];
let descriptors = [{name: 'a'}, {name: 'b'}];
for (let i = 0; i < groupCount; i++) {
descriptors = descriptors.map(descriptor => ({
parent: descriptor,
Field,
}));
groups.push(loadGroup(descriptors));
}

const stream = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream({groups}, webpackMap),
);

const response = ReactServerDOMClient.createFromReadableStream(stream);

// The root row holds only Promises, so it resolves while the field chunk is
// still loading. Subscribing to every group initializes its row, and a
// group finds the group above it blocked.
const form = await response;
const allGroups = Promise.all(form.groups);
loadFieldChunk();
const resolvedGroups = await allGroups;

expect(resolvedGroups).toHaveLength(groupCount);

if (__DEV__) {
// A group resolves to an array, so Flight hands the debug info of the row
// to that array, which is what DevTools reads. Every group contributes a
// fixed number of entries, so the last group holds a multiple of the
// group count. Without deduplication in transferReferencedDebugInfo the
// count doubles at every group.
expect(resolvedGroups[groupCount - 1]._debugInfo.length).toBeLessThan(
100,
);
}
});

describe('abort signal lifetime', () => {
// Collects the lifetime signal that React bounds each abort listener with.
// React passes that signal to addEventListener instead of calling
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4160,4 +4160,59 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});

it('should not exponentially accumulate debug info on deduplicated model chunks', async () => {
// Regression test for debug info that grows exponentially with the length
// of the chain, along the path that resolves a reference synchronously.
// Each page derives its records from the records of the page before it.
// Those records deduplicate to the row of that page, which gives this row
// one reference per record. That page has already resolved by then, and it
// still holds its debug info because it resolves to a plain object. Only an
// array, an async iterable, an element, or a lazy node hands the debug info
// to the value. So each reference copies the entries of the previous page,
// and the count doubles at every page.
const pageCount = 10;

async function loadPage(pageNumber, previousRecords) {
await delay(0);
const records = previousRecords.map(record => ({previous: record}));
return {
records,
nextPage:
pageNumber === pageCount ? null : loadPage(pageNumber + 1, records),
};
}

const stream = ReactServerDOMServer.renderToPipeableStream(
loadPage(1, [{id: 'a'}, {id: 'b'}]),
);

const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);

let page = await result;
let lastPage = null;
let pagesRead = 1;
while (page.nextPage !== null) {
lastPage = page.nextPage;
page = await page.nextPage;
pagesRead++;
}
expect(pagesRead).toBe(pageCount);

await finishLoadingStream(readable);

if (__DEV__) {
// Flight represents a Promise in the model with the chunk of its row, so
// this reads the debug info of the row itself. Every page contributes a
// fixed number of entries, so the last page holds a multiple of the page
// count. Without deduplication in transferReferencedDebugInfo the count
// doubles at every page.
expect(lastPage._debugInfo.length).toBeLessThan(100);
}
});
});
2 changes: 1 addition & 1 deletion packages/shared/ReactFeatureFlags.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
*/
exportconstenableInfiniteRenderLoopDetectionForceThrow: boolean=false;

exportconstenableConditionalUseWarning: boolean=__EXPERIMENTAL__;
exportconstenableConditionalUseWarning: boolean=true;

exportconstenableFragmentRefs: boolean=true;
exportconstenableFragmentRefsScrollIntoView: boolean=true;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@ export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__;
export const enableConditionalUseWarning: boolean = true;

export const enableEffectEventMutationPhase: boolean = true;

Expand Down
Loading
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
44 changes: 33 additions & 11 deletions packages/react-client/src/ReactFlightClient.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,7 @@ type BlockedChunk<T> = {
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
_debugChunk: null, // DEV-only
_debugInfo: ReactDebugInfo, // DEV-only
_receivedDebugInfo: null | Set<ReactDebugInfoEntry>, // DEV-only
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
Expand DownExpand Up@@ -276,6 +277,7 @@ function ReactPromise(status: any, value: any, reason: any) {
if (__DEV__) {
this._debugChunk = null;
this._debugInfo = [];
this._receivedDebugInfo = null;
}
}
// We subclass Promise.prototype so that we get other methods like .catch
Expand DownExpand Up@@ -1170,6 +1172,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
return;
}
}
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
cyclicChunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<T> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
Expand DownExpand Up@@ -1771,7 +1777,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
element.props = mappedValue;
Expand All@@ -1789,7 +1795,7 @@ function fulfillReference(
}
break;
default:
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
break;
Expand All@@ -1810,6 +1816,10 @@ function fulfillReference(
return;
}
const resolveListeners = chunk.value;
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
chunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<any> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = handler.value;
Expand DownExpand Up@@ -2148,25 +2158,37 @@ function resolveLazy(value: any): mixed {
}

function transferReferencedDebugInfo(
parentChunk: null | SomeChunk<any>,
receivingChunk: null | BlockedChunk<any>,
referencedChunk: SomeChunk<any>,
): void {
if (__DEV__) {
// We add the debug info to the initializing chunk since the resolution of
// that promise is also blocked by the referenced debug info. By adding it
// to both we can track it even if the array/element/lazy is extracted, or
// if the root is rendered as is.
if (parentChunk !== null) {
// We add the debug info to the receiving chunk since the resolution of that
// promise is also blocked by the referenced debug info. By adding it to
// both we can track it even if the array/element/lazy is extracted, or if
// the root is rendered as is.
if (receivingChunk !== null) {
const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
const receivingDebugInfo = receivingChunk._debugInfo;
// The receiving chunk takes each entry only once. A repeated entry
// carries no information. An entry repeats in two ways:
//
// - the receiving chunk references the same chunk more than once
// - two referenced chunks carry the same entry
//
// Without the set, the entries multiply along a chain of references.
let receivedDebugInfo = receivingChunk._receivedDebugInfo;
if (receivedDebugInfo === null) {
receivedDebugInfo = receivingChunk._receivedDebugInfo = new Set();
}
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
if (debugInfoEntry.name != null) {
debugInfoEntry as ReactComponentInfo;
// We're not transferring Component info since we use Component info
// in Debug info to fill in gaps between Fibers for the parent stack.
} else {
parentDebugInfo.push(debugInfoEntry);
} else if (!receivedDebugInfo.has(debugInfoEntry)) {
receivedDebugInfo.add(debugInfoEntry);
receivingDebugInfo.push(debugInfoEntry);
}
}
}
Expand Down
7 changes: 1 addition & 6 deletions packages/react-devtools-extensions/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,13 +97,8 @@ const build = async (tempPath, manifestPath, envExtension = {}) => {
);

const commit = getGitCommit();
const dateString = new Date().toLocaleDateString();
const manifest = JSON.parse(readFileSync(copiedManifestPath).toString());
const versionDateString = `${manifest.version} (${dateString})`;
if (manifest.version_name) {
manifest.version_name = versionDateString;
}
manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`;
manifest.description += `\n\nCreated from revision ${commit}.`;

if (process.env.NODE_ENV === 'development') {
// When building the local development version of the
Expand Down
10 changes: 9 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -457,7 +457,15 @@ export function createRenderState(
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
nonceScript === undefined
? importMapScriptStart
: stringToPrecomputedChunk(
'<script type="importmap" nonce="' +
escapeTextForBrowser(nonceScript) +
'">',
),
);
importMapChunks.push(
stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map))),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4520,6 +4520,40 @@ describe('ReactDOMFizzServer', () => {
);
});

it('applies the nonce option to the importmap script', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
nonce: 'R4nd0m',
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap" nonce="R4nd0m">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async="" nonce="R4nd0m"></script>'
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#_R_" blocking="render">'
: ''),
);
});

// bugfix: https://github.com/facebook/react/issues/27286
it('can render custom elements with children on ther server', async () => {
await act(() => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3441,6 +3441,70 @@ describe('ReactFlightDOMBrowser', () => {
);
});

it('should not exponentially accumulate debug info when deduplicated references are blocked', async () => {
// Regression test for debug info that grows exponentially, along the path
// that resolves a reference asynchronously. A form streams its field groups
// in parallel. Every group derives its descriptors from the group above it.
// Those descriptors deduplicate to the row of that group, which gives this
// row one reference per descriptor. Every descriptor names the client
// component of the field, and that chunk has not loaded, so every row
// blocks and the references wait for it. A row hands its debug info to the
// references that wait on it, so each of them copies the whole array and
// the count doubles at every group.
let loadFieldChunk;
const fieldChunkLoaded = new Promise(resolve => (loadFieldChunk = resolve));
const Field = clientExports(
function Field() {
return null;
},
'1',
'/field.js',
fieldChunkLoaded,
);

async function loadGroup(descriptors) {
return descriptors;
}

const groupCount = 10;
const groups = [];
let descriptors = [{name: 'a'}, {name: 'b'}];
for (let i = 0; i < groupCount; i++) {
descriptors = descriptors.map(descriptor => ({
parent: descriptor,
Field,
}));
groups.push(loadGroup(descriptors));
}

const stream = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream({groups}, webpackMap),
);

const response = ReactServerDOMClient.createFromReadableStream(stream);

// The root row holds only Promises, so it resolves while the field chunk is
// still loading. Subscribing to every group initializes its row, and a
// group finds the group above it blocked.
const form = await response;
const allGroups = Promise.all(form.groups);
loadFieldChunk();
const resolvedGroups = await allGroups;

expect(resolvedGroups).toHaveLength(groupCount);

if (__DEV__) {
// A group resolves to an array, so Flight hands the debug info of the row
// to that array, which is what DevTools reads. Every group contributes a
// fixed number of entries, so the last group holds a multiple of the
// group count. Without deduplication in transferReferencedDebugInfo the
// count doubles at every group.
expect(resolvedGroups[groupCount - 1]._debugInfo.length).toBeLessThan(
100,
);
}
});

describe('abort signal lifetime', () => {
// Collects the lifetime signal that React bounds each abort listener with.
// React passes that signal to addEventListener instead of calling
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4160,4 +4160,59 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});

it('should not exponentially accumulate debug info on deduplicated model chunks', async () => {
// Regression test for debug info that grows exponentially with the length
// of the chain, along the path that resolves a reference synchronously.
// Each page derives its records from the records of the page before it.
// Those records deduplicate to the row of that page, which gives this row
// one reference per record. That page has already resolved by then, and it
// still holds its debug info because it resolves to a plain object. Only an
// array, an async iterable, an element, or a lazy node hands the debug info
// to the value. So each reference copies the entries of the previous page,
// and the count doubles at every page.
const pageCount = 10;

async function loadPage(pageNumber, previousRecords) {
await delay(0);
const records = previousRecords.map(record => ({previous: record}));
return {
records,
nextPage:
pageNumber === pageCount ? null : loadPage(pageNumber + 1, records),
};
}

const stream = ReactServerDOMServer.renderToPipeableStream(
loadPage(1, [{id: 'a'}, {id: 'b'}]),
);

const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);

let page = await result;
let lastPage = null;
let pagesRead = 1;
while (page.nextPage !== null) {
lastPage = page.nextPage;
page = await page.nextPage;
pagesRead++;
}
expect(pagesRead).toBe(pageCount);

await finishLoadingStream(readable);

if (__DEV__) {
// Flight represents a Promise in the model with the chunk of its row, so
// this reads the debug info of the row itself. Every page contributes a
// fixed number of entries, so the last page holds a multiple of the page
// count. Without deduplication in transferReferencedDebugInfo the count
// doubles at every page.
expect(lastPage._debugInfo.length).toBeLessThan(100);
}
});
});
2 changes: 1 addition & 1 deletion packages/shared/ReactFeatureFlags.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
*/
exportconstenableInfiniteRenderLoopDetectionForceThrow: boolean=false;

exportconstenableConditionalUseWarning: boolean=__EXPERIMENTAL__;
exportconstenableConditionalUseWarning: boolean=true;

exportconstenableFragmentRefs: boolean=true;
exportconstenableFragmentRefsScrollIntoView: boolean=true;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@ export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__;
export const enableConditionalUseWarning: boolean = true;

export const enableEffectEventMutationPhase: boolean = true;

Expand Down
Loading
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
44 changes: 33 additions & 11 deletions packages/react-client/src/ReactFlightClient.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,7 @@ type BlockedChunk<T> = {
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
_debugChunk: null, // DEV-only
_debugInfo: ReactDebugInfo, // DEV-only
_receivedDebugInfo: null | Set<ReactDebugInfoEntry>, // DEV-only
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
Expand DownExpand Up@@ -276,6 +277,7 @@ function ReactPromise(status: any, value: any, reason: any) {
if (__DEV__) {
this._debugChunk = null;
this._debugInfo = [];
this._receivedDebugInfo = null;
}
}
// We subclass Promise.prototype so that we get other methods like .catch
Expand DownExpand Up@@ -1170,6 +1172,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
return;
}
}
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
cyclicChunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<T> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
Expand DownExpand Up@@ -1771,7 +1777,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
element.props = mappedValue;
Expand All@@ -1789,7 +1795,7 @@ function fulfillReference(
}
break;
default:
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
break;
Expand All@@ -1810,6 +1816,10 @@ function fulfillReference(
return;
}
const resolveListeners = chunk.value;
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
chunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<any> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = handler.value;
Expand DownExpand Up@@ -2148,25 +2158,37 @@ function resolveLazy(value: any): mixed {
}

function transferReferencedDebugInfo(
parentChunk: null | SomeChunk<any>,
receivingChunk: null | BlockedChunk<any>,
referencedChunk: SomeChunk<any>,
): void {
if (__DEV__) {
// We add the debug info to the initializing chunk since the resolution of
// that promise is also blocked by the referenced debug info. By adding it
// to both we can track it even if the array/element/lazy is extracted, or
// if the root is rendered as is.
if (parentChunk !== null) {
// We add the debug info to the receiving chunk since the resolution of that
// promise is also blocked by the referenced debug info. By adding it to
// both we can track it even if the array/element/lazy is extracted, or if
// the root is rendered as is.
if (receivingChunk !== null) {
const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
const receivingDebugInfo = receivingChunk._debugInfo;
// The receiving chunk takes each entry only once. A repeated entry
// carries no information. An entry repeats in two ways:
//
// - the receiving chunk references the same chunk more than once
// - two referenced chunks carry the same entry
//
// Without the set, the entries multiply along a chain of references.
let receivedDebugInfo = receivingChunk._receivedDebugInfo;
if (receivedDebugInfo === null) {
receivedDebugInfo = receivingChunk._receivedDebugInfo = new Set();
}
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
if (debugInfoEntry.name != null) {
debugInfoEntry as ReactComponentInfo;
// We're not transferring Component info since we use Component info
// in Debug info to fill in gaps between Fibers for the parent stack.
} else {
parentDebugInfo.push(debugInfoEntry);
} else if (!receivedDebugInfo.has(debugInfoEntry)) {
receivedDebugInfo.add(debugInfoEntry);
receivingDebugInfo.push(debugInfoEntry);
}
}
}
Expand Down
7 changes: 1 addition & 6 deletions packages/react-devtools-extensions/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,13 +97,8 @@ const build = async (tempPath, manifestPath, envExtension = {}) => {
);

const commit = getGitCommit();
const dateString = new Date().toLocaleDateString();
const manifest = JSON.parse(readFileSync(copiedManifestPath).toString());
const versionDateString = `${manifest.version} (${dateString})`;
if (manifest.version_name) {
manifest.version_name = versionDateString;
}
manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`;
manifest.description += `\n\nCreated from revision ${commit}.`;

if (process.env.NODE_ENV === 'development') {
// When building the local development version of the
Expand Down
10 changes: 9 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -457,7 +457,15 @@ export function createRenderState(
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
nonceScript === undefined
? importMapScriptStart
: stringToPrecomputedChunk(
'<script type="importmap" nonce="' +
escapeTextForBrowser(nonceScript) +
'">',
),
);
importMapChunks.push(
stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map))),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4520,6 +4520,40 @@ describe('ReactDOMFizzServer', () => {
);
});

it('applies the nonce option to the importmap script', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
nonce: 'R4nd0m',
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap" nonce="R4nd0m">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async="" nonce="R4nd0m"></script>'
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#_R_" blocking="render">'
: ''),
);
});

// bugfix: https://github.com/facebook/react/issues/27286
it('can render custom elements with children on ther server', async () => {
await act(() => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3441,6 +3441,70 @@ describe('ReactFlightDOMBrowser', () => {
);
});

it('should not exponentially accumulate debug info when deduplicated references are blocked', async () => {
// Regression test for debug info that grows exponentially, along the path
// that resolves a reference asynchronously. A form streams its field groups
// in parallel. Every group derives its descriptors from the group above it.
// Those descriptors deduplicate to the row of that group, which gives this
// row one reference per descriptor. Every descriptor names the client
// component of the field, and that chunk has not loaded, so every row
// blocks and the references wait for it. A row hands its debug info to the
// references that wait on it, so each of them copies the whole array and
// the count doubles at every group.
let loadFieldChunk;
const fieldChunkLoaded = new Promise(resolve => (loadFieldChunk = resolve));
const Field = clientExports(
function Field() {
return null;
},
'1',
'/field.js',
fieldChunkLoaded,
);

async function loadGroup(descriptors) {
return descriptors;
}

const groupCount = 10;
const groups = [];
let descriptors = [{name: 'a'}, {name: 'b'}];
for (let i = 0; i < groupCount; i++) {
descriptors = descriptors.map(descriptor => ({
parent: descriptor,
Field,
}));
groups.push(loadGroup(descriptors));
}

const stream = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream({groups}, webpackMap),
);

const response = ReactServerDOMClient.createFromReadableStream(stream);

// The root row holds only Promises, so it resolves while the field chunk is
// still loading. Subscribing to every group initializes its row, and a
// group finds the group above it blocked.
const form = await response;
const allGroups = Promise.all(form.groups);
loadFieldChunk();
const resolvedGroups = await allGroups;

expect(resolvedGroups).toHaveLength(groupCount);

if (__DEV__) {
// A group resolves to an array, so Flight hands the debug info of the row
// to that array, which is what DevTools reads. Every group contributes a
// fixed number of entries, so the last group holds a multiple of the
// group count. Without deduplication in transferReferencedDebugInfo the
// count doubles at every group.
expect(resolvedGroups[groupCount - 1]._debugInfo.length).toBeLessThan(
100,
);
}
});

describe('abort signal lifetime', () => {
// Collects the lifetime signal that React bounds each abort listener with.
// React passes that signal to addEventListener instead of calling
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4160,4 +4160,59 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});

it('should not exponentially accumulate debug info on deduplicated model chunks', async () => {
// Regression test for debug info that grows exponentially with the length
// of the chain, along the path that resolves a reference synchronously.
// Each page derives its records from the records of the page before it.
// Those records deduplicate to the row of that page, which gives this row
// one reference per record. That page has already resolved by then, and it
// still holds its debug info because it resolves to a plain object. Only an
// array, an async iterable, an element, or a lazy node hands the debug info
// to the value. So each reference copies the entries of the previous page,
// and the count doubles at every page.
const pageCount = 10;

async function loadPage(pageNumber, previousRecords) {
await delay(0);
const records = previousRecords.map(record => ({previous: record}));
return {
records,
nextPage:
pageNumber === pageCount ? null : loadPage(pageNumber + 1, records),
};
}

const stream = ReactServerDOMServer.renderToPipeableStream(
loadPage(1, [{id: 'a'}, {id: 'b'}]),
);

const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);

let page = await result;
let lastPage = null;
let pagesRead = 1;
while (page.nextPage !== null) {
lastPage = page.nextPage;
page = await page.nextPage;
pagesRead++;
}
expect(pagesRead).toBe(pageCount);

await finishLoadingStream(readable);

if (__DEV__) {
// Flight represents a Promise in the model with the chunk of its row, so
// this reads the debug info of the row itself. Every page contributes a
// fixed number of entries, so the last page holds a multiple of the page
// count. Without deduplication in transferReferencedDebugInfo the count
// doubles at every page.
expect(lastPage._debugInfo.length).toBeLessThan(100);
}
});
});
2 changes: 1 addition & 1 deletion packages/shared/ReactFeatureFlags.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
*/
exportconstenableInfiniteRenderLoopDetectionForceThrow: boolean=false;

exportconstenableConditionalUseWarning: boolean=__EXPERIMENTAL__;
exportconstenableConditionalUseWarning: boolean=true;

exportconstenableFragmentRefs: boolean=true;
exportconstenableFragmentRefsScrollIntoView: boolean=true;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@ export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__;
export const enableConditionalUseWarning: boolean = true;

export const enableEffectEventMutationPhase: boolean = true;

Expand Down
Loading
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
44 changes: 33 additions & 11 deletions packages/react-client/src/ReactFlightClient.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,7 @@ type BlockedChunk<T> = {
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
_debugChunk: null, // DEV-only
_debugInfo: ReactDebugInfo, // DEV-only
_receivedDebugInfo: null | Set<ReactDebugInfoEntry>, // DEV-only
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
Expand DownExpand Up@@ -276,6 +277,7 @@ function ReactPromise(status: any, value: any, reason: any) {
if (__DEV__) {
this._debugChunk = null;
this._debugInfo = [];
this._receivedDebugInfo = null;
}
}
// We subclass Promise.prototype so that we get other methods like .catch
Expand DownExpand Up@@ -1170,6 +1172,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
return;
}
}
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
cyclicChunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<T> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
Expand DownExpand Up@@ -1771,7 +1777,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
element.props = mappedValue;
Expand All@@ -1789,7 +1795,7 @@ function fulfillReference(
}
break;
default:
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
break;
Expand All@@ -1810,6 +1816,10 @@ function fulfillReference(
return;
}
const resolveListeners = chunk.value;
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
chunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<any> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = handler.value;
Expand DownExpand Up@@ -2148,25 +2158,37 @@ function resolveLazy(value: any): mixed {
}

function transferReferencedDebugInfo(
parentChunk: null | SomeChunk<any>,
receivingChunk: null | BlockedChunk<any>,
referencedChunk: SomeChunk<any>,
): void {
if (__DEV__) {
// We add the debug info to the initializing chunk since the resolution of
// that promise is also blocked by the referenced debug info. By adding it
// to both we can track it even if the array/element/lazy is extracted, or
// if the root is rendered as is.
if (parentChunk !== null) {
// We add the debug info to the receiving chunk since the resolution of that
// promise is also blocked by the referenced debug info. By adding it to
// both we can track it even if the array/element/lazy is extracted, or if
// the root is rendered as is.
if (receivingChunk !== null) {
const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
const receivingDebugInfo = receivingChunk._debugInfo;
// The receiving chunk takes each entry only once. A repeated entry
// carries no information. An entry repeats in two ways:
//
// - the receiving chunk references the same chunk more than once
// - two referenced chunks carry the same entry
//
// Without the set, the entries multiply along a chain of references.
let receivedDebugInfo = receivingChunk._receivedDebugInfo;
if (receivedDebugInfo === null) {
receivedDebugInfo = receivingChunk._receivedDebugInfo = new Set();
}
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
if (debugInfoEntry.name != null) {
debugInfoEntry as ReactComponentInfo;
// We're not transferring Component info since we use Component info
// in Debug info to fill in gaps between Fibers for the parent stack.
} else {
parentDebugInfo.push(debugInfoEntry);
} else if (!receivedDebugInfo.has(debugInfoEntry)) {
receivedDebugInfo.add(debugInfoEntry);
receivingDebugInfo.push(debugInfoEntry);
}
}
}
Expand Down
7 changes: 1 addition & 6 deletions packages/react-devtools-extensions/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,13 +97,8 @@ const build = async (tempPath, manifestPath, envExtension = {}) => {
);

const commit = getGitCommit();
const dateString = new Date().toLocaleDateString();
const manifest = JSON.parse(readFileSync(copiedManifestPath).toString());
const versionDateString = `${manifest.version} (${dateString})`;
if (manifest.version_name) {
manifest.version_name = versionDateString;
}
manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`;
manifest.description += `\n\nCreated from revision ${commit}.`;

if (process.env.NODE_ENV === 'development') {
// When building the local development version of the
Expand Down
10 changes: 9 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -457,7 +457,15 @@ export function createRenderState(
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
nonceScript === undefined
? importMapScriptStart
: stringToPrecomputedChunk(
'<script type="importmap" nonce="' +
escapeTextForBrowser(nonceScript) +
'">',
),
);
importMapChunks.push(
stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map))),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4520,6 +4520,40 @@ describe('ReactDOMFizzServer', () => {
);
});

it('applies the nonce option to the importmap script', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
nonce: 'R4nd0m',
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap" nonce="R4nd0m">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async="" nonce="R4nd0m"></script>'
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#_R_" blocking="render">'
: ''),
);
});

// bugfix: https://github.com/facebook/react/issues/27286
it('can render custom elements with children on ther server', async () => {
await act(() => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3441,6 +3441,70 @@ describe('ReactFlightDOMBrowser', () => {
);
});

it('should not exponentially accumulate debug info when deduplicated references are blocked', async () => {
// Regression test for debug info that grows exponentially, along the path
// that resolves a reference asynchronously. A form streams its field groups
// in parallel. Every group derives its descriptors from the group above it.
// Those descriptors deduplicate to the row of that group, which gives this
// row one reference per descriptor. Every descriptor names the client
// component of the field, and that chunk has not loaded, so every row
// blocks and the references wait for it. A row hands its debug info to the
// references that wait on it, so each of them copies the whole array and
// the count doubles at every group.
let loadFieldChunk;
const fieldChunkLoaded = new Promise(resolve => (loadFieldChunk = resolve));
const Field = clientExports(
function Field() {
return null;
},
'1',
'/field.js',
fieldChunkLoaded,
);

async function loadGroup(descriptors) {
return descriptors;
}

const groupCount = 10;
const groups = [];
let descriptors = [{name: 'a'}, {name: 'b'}];
for (let i = 0; i < groupCount; i++) {
descriptors = descriptors.map(descriptor => ({
parent: descriptor,
Field,
}));
groups.push(loadGroup(descriptors));
}

const stream = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream({groups}, webpackMap),
);

const response = ReactServerDOMClient.createFromReadableStream(stream);

// The root row holds only Promises, so it resolves while the field chunk is
// still loading. Subscribing to every group initializes its row, and a
// group finds the group above it blocked.
const form = await response;
const allGroups = Promise.all(form.groups);
loadFieldChunk();
const resolvedGroups = await allGroups;

expect(resolvedGroups).toHaveLength(groupCount);

if (__DEV__) {
// A group resolves to an array, so Flight hands the debug info of the row
// to that array, which is what DevTools reads. Every group contributes a
// fixed number of entries, so the last group holds a multiple of the
// group count. Without deduplication in transferReferencedDebugInfo the
// count doubles at every group.
expect(resolvedGroups[groupCount - 1]._debugInfo.length).toBeLessThan(
100,
);
}
});

describe('abort signal lifetime', () => {
// Collects the lifetime signal that React bounds each abort listener with.
// React passes that signal to addEventListener instead of calling
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4160,4 +4160,59 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});

it('should not exponentially accumulate debug info on deduplicated model chunks', async () => {
// Regression test for debug info that grows exponentially with the length
// of the chain, along the path that resolves a reference synchronously.
// Each page derives its records from the records of the page before it.
// Those records deduplicate to the row of that page, which gives this row
// one reference per record. That page has already resolved by then, and it
// still holds its debug info because it resolves to a plain object. Only an
// array, an async iterable, an element, or a lazy node hands the debug info
// to the value. So each reference copies the entries of the previous page,
// and the count doubles at every page.
const pageCount = 10;

async function loadPage(pageNumber, previousRecords) {
await delay(0);
const records = previousRecords.map(record => ({previous: record}));
return {
records,
nextPage:
pageNumber === pageCount ? null : loadPage(pageNumber + 1, records),
};
}

const stream = ReactServerDOMServer.renderToPipeableStream(
loadPage(1, [{id: 'a'}, {id: 'b'}]),
);

const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);

let page = await result;
let lastPage = null;
let pagesRead = 1;
while (page.nextPage !== null) {
lastPage = page.nextPage;
page = await page.nextPage;
pagesRead++;
}
expect(pagesRead).toBe(pageCount);

await finishLoadingStream(readable);

if (__DEV__) {
// Flight represents a Promise in the model with the chunk of its row, so
// this reads the debug info of the row itself. Every page contributes a
// fixed number of entries, so the last page holds a multiple of the page
// count. Without deduplication in transferReferencedDebugInfo the count
// doubles at every page.
expect(lastPage._debugInfo.length).toBeLessThan(100);
}
});
});
2 changes: 1 addition & 1 deletion packages/shared/ReactFeatureFlags.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
*/
exportconstenableInfiniteRenderLoopDetectionForceThrow: boolean=false;

exportconstenableConditionalUseWarning: boolean=__EXPERIMENTAL__;
exportconstenableConditionalUseWarning: boolean=true;

exportconstenableFragmentRefs: boolean=true;
exportconstenableFragmentRefsScrollIntoView: boolean=true;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@ export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__;
export const enableConditionalUseWarning: boolean = true;

export const enableEffectEventMutationPhase: boolean = true;

Expand Down
Loading
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
44 changes: 33 additions & 11 deletions packages/react-client/src/ReactFlightClient.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,7 @@ type BlockedChunk<T> = {
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
_debugChunk: null, // DEV-only
_debugInfo: ReactDebugInfo, // DEV-only
_receivedDebugInfo: null | Set<ReactDebugInfoEntry>, // DEV-only
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
Expand DownExpand Up@@ -276,6 +277,7 @@ function ReactPromise(status: any, value: any, reason: any) {
if (__DEV__) {
this._debugChunk = null;
this._debugInfo = [];
this._receivedDebugInfo = null;
}
}
// We subclass Promise.prototype so that we get other methods like .catch
Expand DownExpand Up@@ -1170,6 +1172,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
return;
}
}
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
cyclicChunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<T> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
Expand DownExpand Up@@ -1771,7 +1777,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
element.props = mappedValue;
Expand All@@ -1789,7 +1795,7 @@ function fulfillReference(
}
break;
default:
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
break;
Expand All@@ -1810,6 +1816,10 @@ function fulfillReference(
return;
}
const resolveListeners = chunk.value;
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
chunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<any> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = handler.value;
Expand DownExpand Up@@ -2148,25 +2158,37 @@ function resolveLazy(value: any): mixed {
}

function transferReferencedDebugInfo(
parentChunk: null | SomeChunk<any>,
receivingChunk: null | BlockedChunk<any>,
referencedChunk: SomeChunk<any>,
): void {
if (__DEV__) {
// We add the debug info to the initializing chunk since the resolution of
// that promise is also blocked by the referenced debug info. By adding it
// to both we can track it even if the array/element/lazy is extracted, or
// if the root is rendered as is.
if (parentChunk !== null) {
// We add the debug info to the receiving chunk since the resolution of that
// promise is also blocked by the referenced debug info. By adding it to
// both we can track it even if the array/element/lazy is extracted, or if
// the root is rendered as is.
if (receivingChunk !== null) {
const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
const receivingDebugInfo = receivingChunk._debugInfo;
// The receiving chunk takes each entry only once. A repeated entry
// carries no information. An entry repeats in two ways:
//
// - the receiving chunk references the same chunk more than once
// - two referenced chunks carry the same entry
//
// Without the set, the entries multiply along a chain of references.
let receivedDebugInfo = receivingChunk._receivedDebugInfo;
if (receivedDebugInfo === null) {
receivedDebugInfo = receivingChunk._receivedDebugInfo = new Set();
}
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
if (debugInfoEntry.name != null) {
debugInfoEntry as ReactComponentInfo;
// We're not transferring Component info since we use Component info
// in Debug info to fill in gaps between Fibers for the parent stack.
} else {
parentDebugInfo.push(debugInfoEntry);
} else if (!receivedDebugInfo.has(debugInfoEntry)) {
receivedDebugInfo.add(debugInfoEntry);
receivingDebugInfo.push(debugInfoEntry);
}
}
}
Expand Down
7 changes: 1 addition & 6 deletions packages/react-devtools-extensions/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,13 +97,8 @@ const build = async (tempPath, manifestPath, envExtension = {}) => {
);

const commit = getGitCommit();
const dateString = new Date().toLocaleDateString();
const manifest = JSON.parse(readFileSync(copiedManifestPath).toString());
const versionDateString = `${manifest.version} (${dateString})`;
if (manifest.version_name) {
manifest.version_name = versionDateString;
}
manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`;
manifest.description += `\n\nCreated from revision ${commit}.`;

if (process.env.NODE_ENV === 'development') {
// When building the local development version of the
Expand Down
10 changes: 9 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -457,7 +457,15 @@ export function createRenderState(
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
nonceScript === undefined
? importMapScriptStart
: stringToPrecomputedChunk(
'<script type="importmap" nonce="' +
escapeTextForBrowser(nonceScript) +
'">',
),
);
importMapChunks.push(
stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map))),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4520,6 +4520,40 @@ describe('ReactDOMFizzServer', () => {
);
});

it('applies the nonce option to the importmap script', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
nonce: 'R4nd0m',
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap" nonce="R4nd0m">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async="" nonce="R4nd0m"></script>'
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#_R_" blocking="render">'
: ''),
);
});

// bugfix: https://github.com/facebook/react/issues/27286
it('can render custom elements with children on ther server', async () => {
await act(() => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3441,6 +3441,70 @@ describe('ReactFlightDOMBrowser', () => {
);
});

it('should not exponentially accumulate debug info when deduplicated references are blocked', async () => {
// Regression test for debug info that grows exponentially, along the path
// that resolves a reference asynchronously. A form streams its field groups
// in parallel. Every group derives its descriptors from the group above it.
// Those descriptors deduplicate to the row of that group, which gives this
// row one reference per descriptor. Every descriptor names the client
// component of the field, and that chunk has not loaded, so every row
// blocks and the references wait for it. A row hands its debug info to the
// references that wait on it, so each of them copies the whole array and
// the count doubles at every group.
let loadFieldChunk;
const fieldChunkLoaded = new Promise(resolve => (loadFieldChunk = resolve));
const Field = clientExports(
function Field() {
return null;
},
'1',
'/field.js',
fieldChunkLoaded,
);

async function loadGroup(descriptors) {
return descriptors;
}

const groupCount = 10;
const groups = [];
let descriptors = [{name: 'a'}, {name: 'b'}];
for (let i = 0; i < groupCount; i++) {
descriptors = descriptors.map(descriptor => ({
parent: descriptor,
Field,
}));
groups.push(loadGroup(descriptors));
}

const stream = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream({groups}, webpackMap),
);

const response = ReactServerDOMClient.createFromReadableStream(stream);

// The root row holds only Promises, so it resolves while the field chunk is
// still loading. Subscribing to every group initializes its row, and a
// group finds the group above it blocked.
const form = await response;
const allGroups = Promise.all(form.groups);
loadFieldChunk();
const resolvedGroups = await allGroups;

expect(resolvedGroups).toHaveLength(groupCount);

if (__DEV__) {
// A group resolves to an array, so Flight hands the debug info of the row
// to that array, which is what DevTools reads. Every group contributes a
// fixed number of entries, so the last group holds a multiple of the
// group count. Without deduplication in transferReferencedDebugInfo the
// count doubles at every group.
expect(resolvedGroups[groupCount - 1]._debugInfo.length).toBeLessThan(
100,
);
}
});

describe('abort signal lifetime', () => {
// Collects the lifetime signal that React bounds each abort listener with.
// React passes that signal to addEventListener instead of calling
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4160,4 +4160,59 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});

it('should not exponentially accumulate debug info on deduplicated model chunks', async () => {
// Regression test for debug info that grows exponentially with the length
// of the chain, along the path that resolves a reference synchronously.
// Each page derives its records from the records of the page before it.
// Those records deduplicate to the row of that page, which gives this row
// one reference per record. That page has already resolved by then, and it
// still holds its debug info because it resolves to a plain object. Only an
// array, an async iterable, an element, or a lazy node hands the debug info
// to the value. So each reference copies the entries of the previous page,
// and the count doubles at every page.
const pageCount = 10;

async function loadPage(pageNumber, previousRecords) {
await delay(0);
const records = previousRecords.map(record => ({previous: record}));
return {
records,
nextPage:
pageNumber === pageCount ? null : loadPage(pageNumber + 1, records),
};
}

const stream = ReactServerDOMServer.renderToPipeableStream(
loadPage(1, [{id: 'a'}, {id: 'b'}]),
);

const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);

let page = await result;
let lastPage = null;
let pagesRead = 1;
while (page.nextPage !== null) {
lastPage = page.nextPage;
page = await page.nextPage;
pagesRead++;
}
expect(pagesRead).toBe(pageCount);

await finishLoadingStream(readable);

if (__DEV__) {
// Flight represents a Promise in the model with the chunk of its row, so
// this reads the debug info of the row itself. Every page contributes a
// fixed number of entries, so the last page holds a multiple of the page
// count. Without deduplication in transferReferencedDebugInfo the count
// doubles at every page.
expect(lastPage._debugInfo.length).toBeLessThan(100);
}
});
});
2 changes: 1 addition & 1 deletion packages/shared/ReactFeatureFlags.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
*/
exportconstenableInfiniteRenderLoopDetectionForceThrow: boolean=false;

exportconstenableConditionalUseWarning: boolean=__EXPERIMENTAL__;
exportconstenableConditionalUseWarning: boolean=true;

exportconstenableFragmentRefs: boolean=true;
exportconstenableFragmentRefsScrollIntoView: boolean=true;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@ export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__;
export const enableConditionalUseWarning: boolean = true;

export const enableEffectEventMutationPhase: boolean = true;

Expand Down
Loading
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
44 changes: 33 additions & 11 deletions packages/react-client/src/ReactFlightClient.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,7 @@ type BlockedChunk<T> = {
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
_debugChunk: null, // DEV-only
_debugInfo: ReactDebugInfo, // DEV-only
_receivedDebugInfo: null | Set<ReactDebugInfoEntry>, // DEV-only
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
};
type ResolvedModelChunk<T> = {
Expand DownExpand Up@@ -276,6 +277,7 @@ function ReactPromise(status: any, value: any, reason: any) {
if (__DEV__) {
this._debugChunk = null;
this._debugInfo = [];
this._receivedDebugInfo = null;
}
}
// We subclass Promise.prototype so that we get other methods like .catch
Expand DownExpand Up@@ -1170,6 +1172,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
return;
}
}
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
cyclicChunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<T> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
Expand DownExpand Up@@ -1771,7 +1777,7 @@ function fulfillReference(
const element: any = handler.value;
switch (key) {
case '3':
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
element.props = mappedValue;
Expand All@@ -1789,7 +1795,7 @@ function fulfillReference(
}
break;
default:
if (__DEV__) {
if (__DEV__ && !reference.isDebug) {
transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
}
break;
Expand All@@ -1810,6 +1816,10 @@ function fulfillReference(
return;
}
const resolveListeners = chunk.value;
if (__DEV__) {
// Only a blocked chunk receives debug info, so release the set here.
chunk._receivedDebugInfo = null;
}
const initializedChunk: InitializedChunk<any> = chunk as any;
initializedChunk.status = INITIALIZED;
initializedChunk.value = handler.value;
Expand DownExpand Up@@ -2148,25 +2158,37 @@ function resolveLazy(value: any): mixed {
}

function transferReferencedDebugInfo(
parentChunk: null | SomeChunk<any>,
receivingChunk: null | BlockedChunk<any>,
referencedChunk: SomeChunk<any>,
): void {
if (__DEV__) {
// We add the debug info to the initializing chunk since the resolution of
// that promise is also blocked by the referenced debug info. By adding it
// to both we can track it even if the array/element/lazy is extracted, or
// if the root is rendered as is.
if (parentChunk !== null) {
// We add the debug info to the receiving chunk since the resolution of that
// promise is also blocked by the referenced debug info. By adding it to
// both we can track it even if the array/element/lazy is extracted, or if
// the root is rendered as is.
if (receivingChunk !== null) {
const referencedDebugInfo = referencedChunk._debugInfo;
const parentDebugInfo = parentChunk._debugInfo;
const receivingDebugInfo = receivingChunk._debugInfo;
// The receiving chunk takes each entry only once. A repeated entry
// carries no information. An entry repeats in two ways:
//
// - the receiving chunk references the same chunk more than once
// - two referenced chunks carry the same entry
//
// Without the set, the entries multiply along a chain of references.
let receivedDebugInfo = receivingChunk._receivedDebugInfo;
if (receivedDebugInfo === null) {
receivedDebugInfo = receivingChunk._receivedDebugInfo = new Set();
}
for (let i = 0; i < referencedDebugInfo.length; ++i) {
const debugInfoEntry = referencedDebugInfo[i];
if (debugInfoEntry.name != null) {
debugInfoEntry as ReactComponentInfo;
// We're not transferring Component info since we use Component info
// in Debug info to fill in gaps between Fibers for the parent stack.
} else {
parentDebugInfo.push(debugInfoEntry);
} else if (!receivedDebugInfo.has(debugInfoEntry)) {
receivedDebugInfo.add(debugInfoEntry);
receivingDebugInfo.push(debugInfoEntry);
}
}
}
Expand Down
7 changes: 1 addition & 6 deletions packages/react-devtools-extensions/build.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,13 +97,8 @@ const build = async (tempPath, manifestPath, envExtension = {}) => {
);

const commit = getGitCommit();
const dateString = new Date().toLocaleDateString();
const manifest = JSON.parse(readFileSync(copiedManifestPath).toString());
const versionDateString = `${manifest.version} (${dateString})`;
if (manifest.version_name) {
manifest.version_name = versionDateString;
}
manifest.description += `\n\nCreated from revision ${commit} on ${dateString}.`;
manifest.description += `\n\nCreated from revision ${commit}.`;

if (process.env.NODE_ENV === 'development') {
// When building the local development version of the
Expand Down
10 changes: 9 additions & 1 deletion packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -457,7 +457,15 @@ export function createRenderState(
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
nonceScript === undefined
? importMapScriptStart
: stringToPrecomputedChunk(
'<script type="importmap" nonce="' +
escapeTextForBrowser(nonceScript) +
'">',
),
);
importMapChunks.push(
stringToChunk(escapeEntireInlineScriptContent(JSON.stringify(map))),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4520,6 +4520,40 @@ describe('ReactDOMFizzServer', () => {
);
});

it('applies the nonce option to the importmap script', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
nonce: 'R4nd0m',
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap" nonce="R4nd0m">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>' +
(gate(flags => flags.shouldUseFizzExternalRuntime)
? '<script src="react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js" async="" nonce="R4nd0m"></script>'
: '') +
(gate(flags => flags.enableFizzBlockingRender)
? '<link rel="expect" href="#_R_" blocking="render">'
: ''),
);
});

// bugfix: https://github.com/facebook/react/issues/27286
it('can render custom elements with children on ther server', async () => {
await act(() => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3441,6 +3441,70 @@ describe('ReactFlightDOMBrowser', () => {
);
});

it('should not exponentially accumulate debug info when deduplicated references are blocked', async () => {
// Regression test for debug info that grows exponentially, along the path
// that resolves a reference asynchronously. A form streams its field groups
// in parallel. Every group derives its descriptors from the group above it.
// Those descriptors deduplicate to the row of that group, which gives this
// row one reference per descriptor. Every descriptor names the client
// component of the field, and that chunk has not loaded, so every row
// blocks and the references wait for it. A row hands its debug info to the
// references that wait on it, so each of them copies the whole array and
// the count doubles at every group.
let loadFieldChunk;
const fieldChunkLoaded = new Promise(resolve => (loadFieldChunk = resolve));
const Field = clientExports(
function Field() {
return null;
},
'1',
'/field.js',
fieldChunkLoaded,
);

async function loadGroup(descriptors) {
return descriptors;
}

const groupCount = 10;
const groups = [];
let descriptors = [{name: 'a'}, {name: 'b'}];
for (let i = 0; i < groupCount; i++) {
descriptors = descriptors.map(descriptor => ({
parent: descriptor,
Field,
}));
groups.push(loadGroup(descriptors));
}

const stream = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream({groups}, webpackMap),
);

const response = ReactServerDOMClient.createFromReadableStream(stream);

// The root row holds only Promises, so it resolves while the field chunk is
// still loading. Subscribing to every group initializes its row, and a
// group finds the group above it blocked.
const form = await response;
const allGroups = Promise.all(form.groups);
loadFieldChunk();
const resolvedGroups = await allGroups;

expect(resolvedGroups).toHaveLength(groupCount);

if (__DEV__) {
// A group resolves to an array, so Flight hands the debug info of the row
// to that array, which is what DevTools reads. Every group contributes a
// fixed number of entries, so the last group holds a multiple of the
// group count. Without deduplication in transferReferencedDebugInfo the
// count doubles at every group.
expect(resolvedGroups[groupCount - 1]._debugInfo.length).toBeLessThan(
100,
);
}
});

describe('abort signal lifetime', () => {
// Collects the lifetime signal that React bounds each abort listener with.
// React passes that signal to addEventListener instead of calling
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4160,4 +4160,59 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});

it('should not exponentially accumulate debug info on deduplicated model chunks', async () => {
// Regression test for debug info that grows exponentially with the length
// of the chain, along the path that resolves a reference synchronously.
// Each page derives its records from the records of the page before it.
// Those records deduplicate to the row of that page, which gives this row
// one reference per record. That page has already resolved by then, and it
// still holds its debug info because it resolves to a plain object. Only an
// array, an async iterable, an element, or a lazy node hands the debug info
// to the value. So each reference copies the entries of the previous page,
// and the count doubles at every page.
const pageCount = 10;

async function loadPage(pageNumber, previousRecords) {
await delay(0);
const records = previousRecords.map(record => ({previous: record}));
return {
records,
nextPage:
pageNumber === pageCount ? null : loadPage(pageNumber + 1, records),
};
}

const stream = ReactServerDOMServer.renderToPipeableStream(
loadPage(1, [{id: 'a'}, {id: 'b'}]),
);

const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);

let page = await result;
let lastPage = null;
let pagesRead = 1;
while (page.nextPage !== null) {
lastPage = page.nextPage;
page = await page.nextPage;
pagesRead++;
}
expect(pagesRead).toBe(pageCount);

await finishLoadingStream(readable);

if (__DEV__) {
// Flight represents a Promise in the model with the chunk of its row, so
// this reads the debug info of the row itself. Every page contributes a
// fixed number of entries, so the last page holds a multiple of the page
// count. Without deduplication in transferReferencedDebugInfo the count
// doubles at every page.
expect(lastPage._debugInfo.length).toBeLessThan(100);
}
});
});
2 changes: 1 addition & 1 deletion packages/shared/ReactFeatureFlags.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
*/
exportconstenableInfiniteRenderLoopDetectionForceThrow: boolean=false;

exportconstenableConditionalUseWarning: boolean=__EXPERIMENTAL__;
exportconstenableConditionalUseWarning: boolean=true;

exportconstenableFragmentRefs: boolean=true;
exportconstenableFragmentRefsScrollIntoView: boolean=true;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/forks/ReactFeatureFlags.test-renderer.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@ export const disableClientCache: boolean = true;

export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
export const enableConditionalUseWarning: boolean = __EXPERIMENTAL__;
export const enableConditionalUseWarning: boolean = true;

export const enableEffectEventMutationPhase: boolean = true;

Expand Down
Loading
Loading