Merge 96d9825588 into sapling-pr-archive-josephsavona

This commit is contained in:
Joseph Savona
2025-09-03 17:49:01 -07:00
committed by GitHub
36 changed files with 1482 additions and 496 deletions
@@ -6,52 +6,51 @@
*/
import MonacoEditor, {loader, type Monaco} from '@monaco-editor/react';
import {parseConfigPragmaAsString} from 'babel-plugin-react-compiler';
import type {editor} from 'monaco-editor';
import * as monaco from 'monaco-editor';
import parserBabel from 'prettier/plugins/babel';
import * as prettierPluginEstree from 'prettier/plugins/estree';
import * as prettier from 'prettier/standalone';
import {useState, useEffect} from 'react';
import {useState} from 'react';
import {Resizable} from 're-resizable';
import {useStore} from '../StoreContext';
import {useStore, useStoreDispatch} from '../StoreContext';
import {monacoOptions} from './monacoOptions';
import {
generateOverridePragmaFromConfig,
updateSourceWithOverridePragma,
} from '../../lib/configUtils';
loader.config({monaco});
export default function ConfigEditor(): JSX.Element {
const [, setMonaco] = useState<Monaco | null>(null);
const store = useStore();
const dispatchStore = useStoreDispatch();
// Parse string-based override config from pragma comment and format it
const [configJavaScript, setConfigJavaScript] = useState('');
const handleChange: (value: string | undefined) => void = async value => {
if (value === undefined) return;
useEffect(() => {
const pragma = store.source.substring(0, store.source.indexOf('\n'));
const configString = `(${parseConfigPragmaAsString(pragma)})`;
try {
const newPragma = await generateOverridePragmaFromConfig(value);
const updatedSource = updateSourceWithOverridePragma(
store.source,
newPragma,
);
prettier
.format(configString, {
semi: true,
parser: 'babel-ts',
plugins: [parserBabel, prettierPluginEstree],
})
.then(formatted => {
setConfigJavaScript(formatted);
})
.catch(error => {
console.error('Error formatting config:', error);
setConfigJavaScript('({})'); // Return empty object if not valid for now
//TODO: Add validation and error handling for config
// Update the store with both the new config and updated source
dispatchStore({
type: 'updateFile',
payload: {
source: updatedSource,
config: value,
},
});
console.log('Config:', configString);
}, [store.source]);
const handleChange: (value: string | undefined) => void = value => {
if (!value) return;
// TODO: Implement sync logic to update pragma comments in the source
console.log('Config changed:', value);
} catch (_) {
dispatchStore({
type: 'updateFile',
payload: {
source: store.source,
config: value,
},
});
}
};
const handleMount: (
@@ -81,12 +80,11 @@ export default function ConfigEditor(): JSX.Element {
<MonacoEditor
path={'config.js'}
language={'javascript'}
value={configJavaScript}
value={store.config}
onMount={handleMount}
onChange={handleChange}
options={{
...monacoOptions,
readOnly: true,
lineNumbers: 'off',
folding: false,
renderLineHighlight: 'none',
@@ -48,6 +48,7 @@ import {
import {transformFromAstSync} from '@babel/core';
import {LoggerEvent} from 'babel-plugin-react-compiler/dist/Entrypoint';
import {useSearchParams} from 'next/navigation';
import {parseAndFormatConfig} from '../../lib/configUtils';
function parseInput(
input: string,
@@ -315,9 +316,17 @@ export default function Editor(): JSX.Element {
});
mountStore = defaultStore;
}
dispatchStore({
type: 'setStore',
payload: {store: mountStore},
parseAndFormatConfig(mountStore.source).then(config => {
dispatchStore({
type: 'setStore',
payload: {
store: {
...mountStore,
config,
},
},
});
});
});
@@ -17,6 +17,7 @@ import {useStore, useStoreDispatch} from '../StoreContext';
import {monacoOptions} from './monacoOptions';
// @ts-expect-error TODO: Make TS recognize .d.ts files, in addition to loading them with webpack.
import React$Types from '../../node_modules/@types/react/index.d.ts';
import {parseAndFormatConfig} from '../../lib/configUtils.ts';
loader.config({monaco});
@@ -79,13 +80,17 @@ export default function Input({errors, language}: Props): JSX.Element {
});
}, [monaco, language]);
const handleChange: (value: string | undefined) => void = value => {
const handleChange: (value: string | undefined) => void = async value => {
if (!value) return;
// Parse and format the config
const config = await parseAndFormatConfig(value);
dispatchStore({
type: 'updateFile',
payload: {
source: value,
config,
},
});
};
@@ -56,6 +56,7 @@ type ReducerAction =
type: 'updateFile';
payload: {
source: string;
config?: string;
};
};
@@ -66,10 +67,11 @@ function storeReducer(store: Store, action: ReducerAction): Store {
return newStore;
}
case 'updateFile': {
const {source} = action.payload;
const {source, config} = action.payload;
const newStore = {
...store,
source,
config,
};
return newStore;
}
@@ -0,0 +1,87 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import parserBabel from 'prettier/plugins/babel';
import prettierPluginEstree from 'prettier/plugins/estree';
import * as prettier from 'prettier/standalone';
import {parseConfigPragmaAsString} from '../../../packages/babel-plugin-react-compiler/src/Utils/TestUtils';
/**
* Parse config from pragma and format it with prettier
*/
export async function parseAndFormatConfig(source: string): Promise<string> {
const pragma = source.substring(0, source.indexOf('\n'));
let configString = parseConfigPragmaAsString(pragma);
if (configString !== '') {
configString = `(${configString})`;
}
try {
const formatted = await prettier.format(configString, {
semi: true,
parser: 'babel-ts',
plugins: [parserBabel, prettierPluginEstree],
});
return formatted;
} catch (error) {
console.error('Error formatting config:', error);
return ''; // Return empty string if not valid for now
}
}
function extractCurlyBracesContent(input: string): string {
const startIndex = input.indexOf('{');
const endIndex = input.lastIndexOf('}');
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
throw new Error('No outer curly braces found in input');
}
return input.slice(startIndex, endIndex + 1);
}
function cleanContent(content: string): string {
return content
.replace(/[\r\n]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Generate a the override pragma comment from a formatted config object string
*/
export async function generateOverridePragmaFromConfig(
formattedConfigString: string,
): Promise<string> {
const content = extractCurlyBracesContent(formattedConfigString);
const cleanConfig = cleanContent(content);
// Format the config to ensure it's valid
await prettier.format(`(${cleanConfig})`, {
semi: false,
parser: 'babel-ts',
plugins: [parserBabel, prettierPluginEstree],
});
return `// @OVERRIDE:${cleanConfig}`;
}
/**
* Update the override pragma comment in source code.
*/
export function updateSourceWithOverridePragma(
source: string,
newPragma: string,
): string {
const firstLineEnd = source.indexOf('\n');
const firstLine = source.substring(0, firstLineEnd);
const pragmaRegex = /^\/\/\s*@/;
if (firstLineEnd !== -1 && pragmaRegex.test(firstLine.trim())) {
return newPragma + source.substring(firstLineEnd);
} else {
return newPragma + '\n' + source;
}
}
@@ -15,8 +15,10 @@ export default function MyApp() {
export const defaultStore: Store = {
source: index,
config: '',
};
export const emptyStore: Store = {
source: '',
config: '',
};
@@ -17,6 +17,7 @@ import {defaultStore} from '../defaultStore';
*/
export interface Store {
source: string;
config?: string;
}
export function encodeStore(store: Store): string {
return compressToEncodedURIComponent(JSON.stringify(store));
@@ -65,5 +66,14 @@ export function initStoreFromUrlOrLocalStorage(): Store {
const raw = decodeStore(encodedSource);
invariant(isValidStore(raw), 'Invalid Store');
// Add config property if missing for backwards compatibility
if (!('config' in raw)) {
return {
...raw,
config: '',
};
}
return raw;
}
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10 -6
View File
@@ -34,26 +34,30 @@
"invariant": "^2.2.4",
"lz-string": "^1.5.0",
"monaco-editor": "^0.52.0",
"next": "^15.2.0-canary.64",
"next": "15.5.2",
"notistack": "^3.0.0-alpha.7",
"prettier": "^3.3.3",
"pretty-format": "^29.3.1",
"re-resizable": "^6.9.16",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react": "19.1.1",
"react-dom": "19.1.1"
},
"devDependencies": {
"@types/node": "18.11.9",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"autoprefixer": "^10.4.13",
"clsx": "^1.2.1",
"concurrently": "^7.4.0",
"eslint": "^8.28.0",
"eslint-config-next": "^15.0.1",
"eslint-config-next": "15.5.2",
"monaco-editor-webpack-plugin": "^7.1.0",
"postcss": "^8.4.31",
"tailwindcss": "^3.2.4",
"wait-on": "^7.2.0"
},
"resolutions": {
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9"
}
}
File diff suppressed because it is too large Load Diff
@@ -255,11 +255,16 @@ function parseConfigStringAsJS(
console.log('OVERRIDE:', parsedConfig);
const environment = parseConfigPragmaEnvironmentForTest(
'',
defaults.environment ?? {},
);
const options: Record<keyof PluginOptions, unknown> = {
...defaultOptions,
panicThreshold: 'all_errors',
compilationMode: defaults.compilationMode,
environment: defaults.environment ?? defaultOptions.environment,
environment,
};
// Apply parsed config, merging environment if it exists
@@ -269,22 +274,9 @@ function parseConfigStringAsJS(
...parsedConfig.environment,
};
// Apply complex defaults for environment flags that are set to true
const environmentConfig: Partial<Record<keyof EnvironmentConfig, unknown>> =
{};
for (const [key, value] of Object.entries(mergedEnvironment)) {
if (hasOwnProperty(EnvironmentConfigSchema.shape, key)) {
if (value === true && key in testComplexConfigDefaults) {
environmentConfig[key] = testComplexConfigDefaults[key];
} else {
environmentConfig[key] = value;
}
}
}
// Validate environment config
const validatedEnvironment =
EnvironmentConfigSchema.safeParse(environmentConfig);
EnvironmentConfigSchema.safeParse(mergedEnvironment);
if (!validatedEnvironment.success) {
CompilerError.invariant(false, {
reason: 'Invalid environment configuration in config pragma',
@@ -294,10 +286,6 @@ function parseConfigStringAsJS(
});
}
if (validatedEnvironment.data.enableResetCacheOnSourceFileChanges == null) {
validatedEnvironment.data.enableResetCacheOnSourceFileChanges = false;
}
options.environment = validatedEnvironment.data;
}
@@ -308,9 +296,7 @@ function parseConfigStringAsJS(
}
if (hasOwnProperty(defaultOptions, key)) {
if (value === true && key in testComplexPluginOptionDefaults) {
options[key] = testComplexPluginOptionDefaults[key];
} else if (key === 'target' && value === 'donotuse_meta_internal') {
if (key === 'target' && value === 'donotuse_meta_internal') {
options[key] = {
kind: value,
runtimeModule: 'react',
@@ -48,10 +48,7 @@ export {
printReactiveFunction,
printReactiveFunctionWithOutlined,
} from './ReactiveScopes';
export {
parseConfigPragmaForTests,
parseConfigPragmaAsString,
} from './Utils/TestUtils';
export {parseConfigPragmaForTests} from './Utils/TestUtils';
declare global {
let __DEV__: boolean | null | undefined;
}
+16 -25
View File
@@ -74,13 +74,7 @@ function getDebugChannel(req) {
return activeDebugChannels.get(requestId);
}
async function renderApp(
res,
returnValue,
formState,
noCache,
promiseForDebugChannel
) {
async function renderApp(res, returnValue, formState, noCache, debugChannel) {
const {renderToPipeableStream} = await import(
'react-server-dom-webpack/server'
);
@@ -132,7 +126,7 @@ async function renderApp(
// For client-invoked server actions we refresh the tree and return a return value.
const payload = {root, returnValue, formState};
const {pipe} = renderToPipeableStream(payload, moduleMap, {
debugChannel: await promiseForDebugChannel,
debugChannel,
filterStackFrame,
});
pipe(res);
@@ -385,23 +379,20 @@ app.on('error', function (error) {
if (process.env.NODE_ENV === 'development') {
// Open a websocket server for Debug information
const WebSocket = require('ws');
const webSocketServer = new WebSocket.Server({noServer: true});
httpServer.on('upgrade', (request, socket, head) => {
const DEBUG_CHANNEL_PATH = '/debug-channel?';
if (request.url.startsWith(DEBUG_CHANNEL_PATH)) {
const requestId = request.url.slice(DEBUG_CHANNEL_PATH.length);
const promiseForWs = new Promise(resolve => {
webSocketServer.handleUpgrade(request, socket, head, ws => {
ws.on('close', () => {
activeDebugChannels.delete(requestId);
});
resolve(ws);
});
});
activeDebugChannels.set(requestId, promiseForWs);
} else {
socket.destroy();
}
const webSocketServer = new WebSocket.Server({
server: httpServer,
path: '/debug-channel',
});
webSocketServer.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const requestId = url.searchParams.get('id');
activeDebugChannels.set(requestId, ws);
ws.on('close', (code, reason) => {
activeDebugChannels.delete(requestId);
});
});
}
+44 -14
View File
@@ -14,18 +14,52 @@ function findSourceMapURL(fileName) {
);
}
async function createWebSocketStream(url) {
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, {once: true});
ws.addEventListener('error', reject, {once: true});
});
const writable = new WritableStream({
write(chunk) {
ws.send(chunk);
},
close() {
ws.close();
},
abort(reason) {
ws.close(1000, reason && String(reason));
},
});
const readable = new ReadableStream({
start(controller) {
ws.addEventListener('message', event => {
controller.enqueue(event.data);
});
ws.addEventListener('close', () => {
controller.close();
});
ws.addEventListener('error', err => {
controller.error(err);
});
},
});
return {readable, writable};
}
let updateRoot;
async function callServer(id, args) {
let response;
if (
process.env.NODE_ENV === 'development' &&
typeof WebSocketStream === 'function'
) {
if (process.env.NODE_ENV === 'development') {
const requestId = crypto.randomUUID();
const wss = new WebSocketStream(
'ws://localhost:3001/debug-channel?' + requestId
const debugChannel = await createWebSocketStream(
`ws://localhost:3001/debug-channel?id=${requestId}`
);
const debugChannel = await wss.opened;
response = createFromFetch(
fetch('/', {
method: 'POST',
@@ -74,15 +108,11 @@ function Shell({data}) {
async function hydrateApp() {
let response;
if (
process.env.NODE_ENV === 'development' &&
typeof WebSocketStream === 'function'
) {
if (process.env.NODE_ENV === 'development') {
const requestId = crypto.randomUUID();
const wss = new WebSocketStream(
'ws://localhost:3001/debug-channel?' + requestId
const debugChannel = await createWebSocketStream(
`ws://localhost:3001/debug-channel?id=${requestId}`
);
const debugChannel = await wss.opened;
response = createFromFetch(
fetch('/', {
headers: {
+40 -12
View File
@@ -1010,10 +1010,15 @@ export function reportGlobalError(
if (__DEV__) {
const debugChannel = response._debugChannel;
if (debugChannel !== undefined) {
// If we don't have any more ways of reading data, we don't have to send any
// more neither. So we close the writable side.
// If we don't have any more ways of reading data, we don't have to send
// any more neither. So we close the writable side.
closeDebugChannel(debugChannel);
response._debugChannel = undefined;
// Make sure the debug channel is not closed a second time when the
// Response gets GC:ed.
if (debugChannelRegistry !== null) {
debugChannelRegistry.unregister(response);
}
}
}
}
@@ -1069,7 +1074,14 @@ function getTaskName(type: mixed): string {
}
}
function initializeElement(response: Response, element: any): void {
function initializeElement(
response: Response,
element: any,
lazyType: null | LazyComponent<
React$Element<any>,
SomeChunk<React$Element<any>>,
>,
): void {
if (!__DEV__) {
return;
}
@@ -1136,6 +1148,18 @@ function initializeElement(response: Response, element: any): void {
if (owner !== null) {
initializeFakeStack(response, owner);
}
// In case the JSX runtime has validated the lazy type as a static child, we
// need to transfer this information to the element.
if (
lazyType &&
lazyType._store &&
lazyType._store.validated &&
!element._store.validated
) {
element._store.validated = lazyType._store.validated;
}
// TODO: We should be freezing the element but currently, we might write into
// _debugInfo later. We could move it into _store which remains mutable.
Object.freeze(element.props);
@@ -1148,7 +1172,7 @@ function createElement(
props: mixed,
owner: ?ReactComponentInfo, // DEV-only
stack: ?ReactStackTrace, // DEV-only
validated: number, // DEV-only
validated: 0 | 1 | 2, // DEV-only
):
| React$Element<any>
| LazyComponent<React$Element<any>, SomeChunk<React$Element<any>>> {
@@ -1225,7 +1249,7 @@ function createElement(
handler.reason,
);
if (__DEV__) {
initializeElement(response, element);
initializeElement(response, element, null);
// Conceptually the error happened inside this Element but right before
// it was rendered. We don't have a client side component to render but
// we can add some DebugInfo to explain that this was conceptually a
@@ -1244,7 +1268,7 @@ function createElement(
}
erroredChunk._debugInfo = [erroredComponent];
}
return createLazyChunkWrapper(erroredChunk);
return createLazyChunkWrapper(erroredChunk, validated);
}
if (handler.deps > 0) {
// We have blocked references inside this Element but we can turn this into
@@ -1253,16 +1277,17 @@ function createElement(
createBlockedChunk(response);
handler.value = element;
handler.chunk = blockedChunk;
const lazyType = createLazyChunkWrapper(blockedChunk, validated);
if (__DEV__) {
/// After we have initialized any blocked references, initialize stack etc.
const init = initializeElement.bind(null, response, element);
// After we have initialized any blocked references, initialize stack etc.
const init = initializeElement.bind(null, response, element, lazyType);
blockedChunk.then(init, init);
}
return createLazyChunkWrapper(blockedChunk);
return lazyType;
}
}
if (__DEV__) {
initializeElement(response, element);
initializeElement(response, element, null);
}
return element;
@@ -1270,6 +1295,7 @@ function createElement(
function createLazyChunkWrapper<T>(
chunk: SomeChunk<T>,
validated: 0 | 1 | 2, // DEV-only
): LazyComponent<T, SomeChunk<T>> {
const lazyType: LazyComponent<T, SomeChunk<T>> = {
$$typeof: REACT_LAZY_TYPE,
@@ -1281,6 +1307,8 @@ function createLazyChunkWrapper<T>(
const chunkDebugInfo: ReactDebugInfo =
chunk._debugInfo || (chunk._debugInfo = ([]: ReactDebugInfo));
lazyType._debugInfo = chunkDebugInfo;
// Initialize a store for key validation by the JSX runtime.
lazyType._store = {validated: validated};
}
return lazyType;
}
@@ -2085,7 +2113,7 @@ function parseModelString(
}
// We create a React.lazy wrapper around any lazy values.
// When passed into React, we'll know how to suspend on this.
return createLazyChunkWrapper(chunk);
return createLazyChunkWrapper(chunk, 0);
}
case '@': {
// Promise
@@ -2434,7 +2462,7 @@ function ResponseInstance(
// When a Response gets GC:ed because nobody is referring to any of the
// objects that lazily load from the Response anymore, then we can close
// the debug channel.
debugChannelRegistry.register(this, debugChannel);
debugChannelRegistry.register(this, debugChannel, this);
}
}
}
@@ -80,8 +80,8 @@ export default function Element({data, index, style}: Props): React.Node {
};
// $FlowFixMe[missing-local-annot]
const handleClick = ({metaKey}) => {
if (id !== null) {
const handleClick = ({metaKey, button}) => {
if (id !== null && button === 0) {
logEvent({
event_name: 'select-element',
metadata: {source: 'click-element'},
@@ -16,14 +16,15 @@
.TreeWrapper {
border-top: 1px solid var(--color-border);
flex: 1 1 var(--horizontal-resize-tree-percentage);
flex: 1 1 65%;
display: flex;
flex-direction: row;
height: 100%;
overflow: auto;
}
.InspectedElementWrapper {
flex: 1 1 35%;
flex: 0 0 calc(100% - var(--horizontal-resize-tree-percentage));
overflow-x: hidden;
overflow-y: auto;
}
@@ -59,12 +60,12 @@
.TreeWrapper {
border-top: 1px solid var(--color-border);
flex: 1 1 var(--vertical-resize-tree-percentage);
flex: 1 1 50%;
overflow: hidden;
}
.InspectedElementWrapper {
flex: 1 1 50%;
flex: 0 0 calc(100% - var(--vertical-resize-tree-percentage));
}
.TreeWrapper + .ResizeBarWrapper .ResizeBar {
@@ -2,13 +2,18 @@
width: 100%;
display: flex;
flex-direction: row;
padding: 0 0.25rem;
padding: 0.25rem;
}
.SuspenseTimelineInput {
display: flex;
flex-direction: column;
flex-grow: 1;
/*
* `overflow: auto` will add scrollbars but the input will not actually grow beyond visible content.
* `overflow: hidden` will constrain the input to its visible content.
*/
overflow: hidden;
}
.SuspenseTimelineRootSwitcher {
@@ -16,20 +21,6 @@
max-width: 3rem;
}
.SuspenseTimelineMarkers {
display: flex;
flex-direction: row;
justify-content: space-between;
.SuspenseTimelineProgressIndicator {
align-self: center;
}
.SuspenseTimelineMarkers > * {
flex: 1 1 0;
overflow: visible;
visibility: hidden;
width: 0
}
.SuspenseTimelineActiveMarker {
visibility: visible;
}
@@ -11,14 +11,7 @@ import type {Element, SuspenseNode} from '../../../frontend/types';
import type Store from '../../store';
import * as React from 'react';
import {
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {useContext, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {BridgeContext, StoreContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import {useHighlightHostInstance} from '../hooks';
@@ -112,30 +105,6 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
setValue(max);
}
const markersID = useId();
const markers: React.Node[] = useMemo(() => {
return timeline.map((suspense, index) => {
const takesUpSpace =
suspense.rects !== null &&
suspense.rects.some(rect => {
return rect.width > 0 && rect.height > 0;
});
return takesUpSpace ? (
<option
key={suspense.id}
className={
index === value ? styles.SuspenseTimelineActiveMarker : undefined
}
value={index}>
#{index + 1}
</option>
) : (
<option key={suspense.id} />
);
});
}, [timeline, value]);
if (rootID === undefined) {
return <div className={styles.SuspenseTimelineInput}>Root not found.</div>;
}
@@ -219,25 +188,26 @@ function SuspenseTimelineInput({rootID}: {rootID: Element['id'] | void}) {
}
return (
<div className={styles.SuspenseTimelineInput}>
<input
className={styles.SuspenseTimelineSlider}
type="range"
min={min}
max={max}
list={markersID}
value={value}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onPointerMove={handlePointerMove}
onPointerUp={clearHighlightHostInstance}
ref={inputRef}
/>
<datalist id={markersID} className={styles.SuspenseTimelineMarkers}>
{markers}
</datalist>
</div>
<>
<div>
{value}/{max}
</div>
<div className={styles.SuspenseTimelineInput}>
<input
className={styles.SuspenseTimelineSlider}
type="range"
min={min}
max={max}
value={value}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onPointerMove={handlePointerMove}
onPointerUp={clearHighlightHostInstance}
ref={inputRef}
/>
</div>
</>
);
}
@@ -10,5 +10,5 @@
import * as React from 'react';
export default function SuspenseTreeList(_: {}): React$Node {
return <div>Activity slices</div>;
return <div>Activity slices not implemented yet</div>;
}
+3 -1
View File
@@ -126,6 +126,7 @@ function wwwOnCaughtError(
defaultOnCaughtError(error, errorInfo);
}
const noopOnDefaultTransitionIndicator = noop;
export function createRoot(
container: Element | Document | DocumentFragment,
@@ -137,6 +138,7 @@ export function createRoot(
({
onUncaughtError: wwwOnUncaughtError,
onCaughtError: wwwOnCaughtError,
onDefaultTransitionIndicator: noopOnDefaultTransitionIndicator,
}: any),
options,
),
@@ -155,6 +157,7 @@ export function hydrateRoot(
({
onUncaughtError: wwwOnUncaughtError,
onCaughtError: wwwOnCaughtError,
onDefaultTransitionIndicator: noopOnDefaultTransitionIndicator,
}: any),
options,
),
@@ -211,7 +214,6 @@ function getReactRootElementInContainer(container: any) {
// This isn't reachable because onRecoverableError isn't called in the
// legacy API.
const noopOnRecoverableError = noop;
const noopOnDefaultTransitionIndicator = noop;
function legacyCreateRootFromDOMContainer(
container: Container,
+11 -2
View File
@@ -46,7 +46,10 @@ import {
createPublicRootInstance,
type PublicRootInstance,
} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
import {disableLegacyMode} from 'shared/ReactFeatureFlags';
import {
disableLegacyMode,
enableDefaultTransitionIndicator,
} from 'shared/ReactFeatureFlags';
if (typeof ReactFiberErrorDialog.showErrorDialog !== 'function') {
throw new Error(
@@ -132,6 +135,12 @@ function render(
if (options && options.onRecoverableError !== undefined) {
onRecoverableError = options.onRecoverableError;
}
let onDefaultTransitionIndicator = nativeOnDefaultTransitionIndicator;
if (enableDefaultTransitionIndicator) {
if (options && options.onDefaultTransitionIndicator !== undefined) {
onDefaultTransitionIndicator = options.onDefaultTransitionIndicator;
}
}
const publicRootInstance = createPublicRootInstance(containerTag);
const rootInstance = {
@@ -151,7 +160,7 @@ function render(
onUncaughtError,
onCaughtError,
onRecoverableError,
nativeOnDefaultTransitionIndicator,
onDefaultTransitionIndicator,
null,
);
@@ -134,6 +134,7 @@ export type RenderRootOptions = {
error: mixed,
errorInfo: {+componentStack?: ?string},
) => void,
onDefaultTransitionIndicator?: () => void | (() => void),
};
/**
+17 -3
View File
@@ -73,6 +73,7 @@ import {
includesSomeLane,
isGestureRender,
GestureLane,
UpdateLanes,
} from './ReactFiberLane';
import {
ContinuousEventPriority,
@@ -2983,6 +2984,20 @@ function rerenderDeferredValue<T>(value: T, initialValue?: T): T {
}
}
function isRenderingDeferredWork(): boolean {
if (!includesSomeLane(renderLanes, DeferredLane)) {
// None of the render lanes are deferred lanes.
return false;
}
// At least one of the render lanes are deferred lanes. However, if the
// current render is also batched together with an update, then we can't
// say that the render is wholly the result of deferred work. We can check
// this by checking if the root render lanes contain any "update" lanes, i.e.
// lanes that are only assigned to updates, like setState.
const rootRenderLanes = getWorkInProgressRootRenderLanes();
return !includesSomeLane(rootRenderLanes, UpdateLanes);
}
function mountDeferredValueImpl<T>(hook: Hook, value: T, initialValue?: T): T {
if (
// When `initialValue` is provided, we defer the initial render even if the
@@ -2991,7 +3006,7 @@ function mountDeferredValueImpl<T>(hook: Hook, value: T, initialValue?: T): T {
// However, to avoid waterfalls, we do not defer if this render
// was itself spawned by an earlier useDeferredValue. Check if DeferredLane
// is part of the render lanes.
!includesSomeLane(renderLanes, DeferredLane)
!isRenderingDeferredWork()
) {
// Render with the initial value
hook.memoizedState = initialValue;
@@ -3038,8 +3053,7 @@ function updateDeferredValueImpl<T>(
}
const shouldDeferValue =
!includesOnlyNonUrgentLanes(renderLanes) &&
!includesSomeLane(renderLanes, DeferredLane);
!includesOnlyNonUrgentLanes(renderLanes) && !isRenderingDeferredWork();
if (shouldDeferValue) {
// This is an urgent update. Since the value has changed, keep using the
// previous value and spawn a deferred render to update it later.
+41 -8
View File
@@ -73,6 +73,20 @@ const TransitionLane12: Lane = /* */ 0b0000000000010000000
const TransitionLane13: Lane = /* */ 0b0000000000100000000000000000000;
const TransitionLane14: Lane = /* */ 0b0000000001000000000000000000000;
const TransitionUpdateLanes =
TransitionLane1 |
TransitionLane2 |
TransitionLane3 |
TransitionLane4 |
TransitionLane5 |
TransitionLane6 |
TransitionLane7 |
TransitionLane8 |
TransitionLane9 |
TransitionLane10;
const TransitionDeferredLanes =
TransitionLane11 | TransitionLane12 | TransitionLane13 | TransitionLane14;
const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
const RetryLane1: Lane = /* */ 0b0000000010000000000000000000000;
const RetryLane2: Lane = /* */ 0b0000000100000000000000000000000;
@@ -94,7 +108,7 @@ export const DeferredLane: Lane = /* */ 0b1000000000000000000
// Any lane that might schedule an update. This is used to detect infinite
// update loops, so it doesn't include hydration lanes or retries.
export const UpdateLanes: Lanes =
SyncLane | InputContinuousLane | DefaultLane | TransitionLanes;
SyncLane | InputContinuousLane | DefaultLane | TransitionUpdateLanes;
export const HydrationLanes =
SyncHydrationLane |
@@ -155,7 +169,8 @@ export function getLabelForLane(lane: Lane): string | void {
export const NoTimestamp = -1;
let nextTransitionLane: Lane = TransitionLane1;
let nextTransitionUpdateLane: Lane = TransitionLane1;
let nextTransitionDeferredLane: Lane = TransitionLane11;
let nextRetryLane: Lane = RetryLane1;
function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
@@ -190,11 +205,12 @@ function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
case TransitionLane8:
case TransitionLane9:
case TransitionLane10:
return lanes & TransitionUpdateLanes;
case TransitionLane11:
case TransitionLane12:
case TransitionLane13:
case TransitionLane14:
return lanes & TransitionLanes;
return lanes & TransitionDeferredLanes;
case RetryLane1:
case RetryLane2:
case RetryLane3:
@@ -679,14 +695,23 @@ export function isGestureRender(lanes: Lanes): boolean {
return lanes === GestureLane;
}
export function claimNextTransitionLane(): Lane {
export function claimNextTransitionUpdateLane(): Lane {
// Cycle through the lanes, assigning each new transition to the next lane.
// In most cases, this means every transition gets its own lane, until we
// run out of lanes and cycle back to the beginning.
const lane = nextTransitionLane;
nextTransitionLane <<= 1;
if ((nextTransitionLane & TransitionLanes) === NoLanes) {
nextTransitionLane = TransitionLane1;
const lane = nextTransitionUpdateLane;
nextTransitionUpdateLane <<= 1;
if ((nextTransitionUpdateLane & TransitionUpdateLanes) === NoLanes) {
nextTransitionUpdateLane = TransitionLane1;
}
return lane;
}
export function claimNextTransitionDeferredLane(): Lane {
const lane = nextTransitionDeferredLane;
nextTransitionDeferredLane <<= 1;
if ((nextTransitionDeferredLane & TransitionDeferredLanes) === NoLanes) {
nextTransitionDeferredLane = TransitionLane11;
}
return lane;
}
@@ -952,6 +977,14 @@ function markSpawnedDeferredLane(
// Entangle the spawned lane with the DeferredLane bit so that we know it
// was the result of another render. This lets us avoid a useDeferredValue
// waterfall — only the first level will defer.
// TODO: Now that there is a reserved set of transition lanes that are used
// exclusively for deferred work, we should get rid of this special
// DeferredLane bit; the same information can be inferred by checking whether
// the lane is one of the TransitionDeferredLanes. The only reason this still
// exists is because we need to also do the same for OffscreenLane. That
// requires additional changes because there are more places around the
// codebase that treat OffscreenLane as a magic value; would need to check
// for a new OffscreenDeferredLane, too. Will leave this for a follow-up.
const spawnedLaneIndex = laneToIndex(spawnedLane);
root.entangledLanes |= spawnedLane;
root.entanglements[spawnedLaneIndex] |=
+46 -28
View File
@@ -228,9 +228,20 @@ export function logComponentRender(
? 'tertiary-dark'
: 'primary-dark'
: 'error';
const debugTask = fiber._debugTask;
if (__DEV__ && debugTask) {
if (!__DEV__) {
console.timeStamp(
name,
startTime,
endTime,
COMPONENTS_TRACK,
undefined,
color,
);
} else {
const props = fiber.memoizedProps;
const debugTask = fiber._debugTask;
if (
props !== null &&
alternate !== null &&
@@ -268,38 +279,45 @@ export function logComponentRender(
reusableComponentDevToolDetails.properties = properties;
reusableComponentOptions.start = startTime;
reusableComponentOptions.end = endTime;
if (debugTask != null) {
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(
performance,
'\u200b' + name,
reusableComponentOptions,
),
);
} else {
performance.measure('\u200b' + name, reusableComponentOptions);
}
}
} else {
if (debugTask != null) {
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(
performance,
'\u200b' + name,
reusableComponentOptions,
console.timeStamp.bind(
console,
name,
startTime,
endTime,
COMPONENTS_TRACK,
undefined,
color,
),
);
return;
} else {
console.timeStamp(
name,
startTime,
endTime,
COMPONENTS_TRACK,
undefined,
color,
);
}
}
debugTask.run(
// $FlowFixMe[method-unbinding]
console.timeStamp.bind(
console,
name,
startTime,
endTime,
COMPONENTS_TRACK,
undefined,
color,
),
);
} else {
console.timeStamp(
name,
startTime,
endTime,
COMPONENTS_TRACK,
undefined,
color,
);
}
}
}
+2 -2
View File
@@ -31,7 +31,7 @@ import {
getNextLanes,
includesSyncLane,
markStarvedLanesAsExpired,
claimNextTransitionLane,
claimNextTransitionUpdateLane,
getNextLanesToFlushSync,
checkIfRootIsPrerendering,
isGestureRender,
@@ -716,7 +716,7 @@ export function requestTransitionLane(
: // We may or may not be inside an async action scope. If we are, this
// is the first update in that scope. Either way, we need to get a
// fresh transition lane.
claimNextTransitionLane();
claimNextTransitionUpdateLane();
}
return currentEventTransitionLane;
}
+2 -2
View File
@@ -192,7 +192,7 @@ import {
OffscreenLane,
SyncUpdateLanes,
UpdateLanes,
claimNextTransitionLane,
claimNextTransitionDeferredLane,
checkIfRootIsPrerendering,
includesOnlyViewTransitionEligibleLanes,
isGestureRender,
@@ -827,7 +827,7 @@ export function requestDeferredLane(): Lane {
workInProgressDeferredLane = OffscreenLane;
} else {
// Everything else is spawned as a transition.
workInProgressDeferredLane = claimNextTransitionLane();
workInProgressDeferredLane = claimNextTransitionDeferredLane();
}
}
@@ -608,6 +608,48 @@ describe('ReactDeferredValue', () => {
},
);
it(
"regression: useDeferredValue's initial value argument works even if an unrelated " +
'transition is suspended',
async () => {
// Simulates a previous bug where a new useDeferredValue hook is mounted
// while some unrelated transition is suspended. In the regression case,
// the initial values was skipped/ignored.
function Content({text}) {
return (
<AsyncText text={useDeferredValue(text, `Preview ${text}...`)} />
);
}
function App({text}) {
// Use a key to force a new Content instance to be mounted each time
// the text changes.
return <Content key={text} text={text} />;
}
const root = ReactNoop.createRoot();
// Render a previous UI using useDeferredValue. Suspend on the
// final value.
resolveText('Preview A...');
await act(() => startTransition(() => root.render(<App text="A" />)));
assertLog(['Preview A...', 'Suspend! [A]']);
// While it's still suspended, update the UI to show a different screen
// with a different preview value. We should be able to show the new
// preview even though the previous transition never finished.
resolveText('Preview B...');
await act(() => startTransition(() => root.render(<App text="B" />)));
assertLog(['Preview B...', 'Suspend! [B]']);
// Now finish loading the final value.
await act(() => resolveText('B'));
assertLog(['B']);
expect(root).toMatchRenderedOutput('B');
},
);
it('avoids a useDeferredValue waterfall when separated by a Suspense boundary', async () => {
// Same as the previous test but with a Suspense boundary separating the
// two useDeferredValue hooks.
@@ -2846,4 +2846,64 @@ describe('ReactFlightDOMBrowser', () => {
expect(container.innerHTML).toBe('<p>Hi</p>');
});
it('should not have missing key warnings when a static child is blocked on debug info', async () => {
const ClientComponent = clientExports(function ClientComponent({element}) {
return (
<div>
<span>Hi</span>
{element}
</div>
);
});
let debugReadableStreamController;
const debugReadableStream = new ReadableStream({
start(controller) {
debugReadableStreamController = controller;
},
});
const stream = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream(
<ClientComponent element={<span>Sebbie</span>} />,
webpackMap,
{
debugChannel: {
writable: new WritableStream({
write(chunk) {
debugReadableStreamController.enqueue(chunk);
},
close() {
debugReadableStreamController.close();
},
}),
},
},
),
);
function ClientRoot({response}) {
return use(response);
}
const response = ReactServerDOMClient.createFromReadableStream(stream, {
debugChannel: {readable: createDelayedStream(debugReadableStream)},
});
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await act(() => {
root.render(<ClientRoot response={response} />);
});
// Wait for the debug info to be processed.
await act(() => {});
expect(container.innerHTML).toBe(
'<div><span>Hi</span><span>Sebbie</span></div>',
);
});
});
+3
View File
@@ -59,7 +59,10 @@ export type LazyComponent<T, P> = {
$$typeof: symbol | number,
_payload: P,
_init: (payload: P) => T,
// __DEV__
_debugInfo?: null | ReactDebugInfo,
_store?: {validated: 0 | 1 | 2, ...}, // 0: not validated, 1: validated, 2: force fail
};
function lazyInitializer<T>(payload: Payload<T>): T {
+16
View File
@@ -804,6 +804,14 @@ function validateChildKeys(node) {
if (node._store) {
node._store.validated = 1;
}
} else if (isLazyType(node)) {
if (node._payload.status === 'fulfilled') {
if (isValidElement(node._payload.value) && node._payload.value._store) {
node._payload.value._store.validated = 1;
}
} else if (node._store) {
node._store.validated = 1;
}
}
}
}
@@ -822,3 +830,11 @@ export function isValidElement(object) {
object.$$typeof === REACT_ELEMENT_TYPE
);
}
export function isLazyType(object) {
return (
typeof object === 'object' &&
object !== null &&
object.$$typeof === REACT_LAZY_TYPE
);
}
@@ -79,7 +79,7 @@ export const enableSuspenseyImages: boolean = false;
export const enableFizzBlockingRender: boolean = true;
export const enableSrcObject: boolean = false;
export const enableHydrationChangeEvent: boolean = true;
export const enableDefaultTransitionIndicator: boolean = false;
export const enableDefaultTransitionIndicator: boolean = true;
export const ownerStackLimit = 1e4;
export const enableComponentPerformanceTrack: boolean =
__PROFILE__ && dynamicFlags.enableComponentPerformanceTrack;
@@ -66,7 +66,7 @@ export const enableSuspenseyImages = false;
export const enableFizzBlockingRender = true;
export const enableSrcObject = false;
export const enableHydrationChangeEvent = false;
export const enableDefaultTransitionIndicator = false;
export const enableDefaultTransitionIndicator = true;
export const enableFragmentRefs = false;
export const enableFragmentRefsScrollIntoView = false;
export const ownerStackLimit = 1e4;
@@ -79,7 +79,7 @@ export const enableSuspenseyImages: boolean = false;
export const enableFizzBlockingRender: boolean = true;
export const enableSrcObject: boolean = false;
export const enableHydrationChangeEvent: boolean = false;
export const enableDefaultTransitionIndicator: boolean = false;
export const enableDefaultTransitionIndicator: boolean = true;
export const enableFragmentRefs: boolean = false;
export const enableFragmentRefsScrollIntoView: boolean = false;
@@ -109,7 +109,7 @@ export const enableSuspenseyImages: boolean = false;
export const enableFizzBlockingRender: boolean = true;
export const enableSrcObject: boolean = false;
export const enableHydrationChangeEvent: boolean = false;
export const enableDefaultTransitionIndicator: boolean = false;
export const enableDefaultTransitionIndicator: boolean = true;
export const ownerStackLimit = 1e4;