Upgrade to React 18 (#8368)

Only show in developer changelog
This commit is contained in:
Florian Rival
2026-03-11 17:30:11 +01:00
committed by GitHub
parent 9d0d86e6fb
commit e2ed9993d3
13 changed files with 18860 additions and 11727 deletions
+1 -1
View File
@@ -250,7 +250,7 @@ blocks:
(cd GDJS && git checkout package-lock.json && npm i) (cd GDJS && git checkout package-lock.json && npm i)
- cd newIDE/app - cd newIDE/app
- npm run postinstall - npm run postinstall
- npm run analyze-test-coverage - npm run test
- name: GDJS tests - name: GDJS tests
commands: commands:
+6 -1
View File
@@ -1,4 +1,5 @@
// This file customizes webpack configuration for react-app-rewired. // This file customizes webpack configuration for react-app-rewired.
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
module.exports = { module.exports = {
webpack: function override(config, env) { webpack: function override(config, env) {
@@ -16,12 +17,16 @@ module.exports = {
// but it is not a blocking issue, so we ignore them. // but it is not a blocking issue, so we ignore them.
config.ignoreWarnings = [/Failed to parse source map/]; config.ignoreWarnings = [/Failed to parse source map/];
config.resolve.plugins = config.resolve.plugins.filter(
(plugin) => !(plugin instanceof ModuleScopePlugin)
);
return config; return config;
}, },
jest: function(config) { jest: function(config) {
config.transformIgnorePatterns = [ config.transformIgnorePatterns = [
'<rootDir>/node_modules/(?!react-markdown|unified|remark-parse|mdast-util-from-markdown|micromark|decode-named-character-reference|remark-rehype|trim-lines|hast-util-whitespace|remark-gfm|mdast-util-gfm|mdast-util-find-and-replace|mdast-util-to-markdown|markdown-table|is-plain-obj)', '<rootDir>/node_modules/(?!react-markdown|vfile|unist-.*|unified|bail|trough|character-entities|remark-parse|mdast-util-.*|micromark|decode-named-character-reference|remark-rehype|property-information|hast-util-.*|space-separated-tokens|comma-separated-tokens|ccount|escape-string-regexp|trim-lines|hast-util-whitespace|remark-gfm|mdast-util-gfm|mdast-util-find-and-replace|mdast-util-to-markdown|markdown-table|is-plain-obj)',
]; ];
return config; return config;
+23
View File
@@ -690,3 +690,26 @@ type ReactDOM$SVGElementJSXIntrinsic = {
}, },
... ...
}; };
// React 18 createRoot API
declare module 'react-dom/client' {
declare type RootOptions = {
onRecoverableError?: (error: mixed) => void,
identifierPrefix?: string,
...
};
declare type Root = {
render(children: React$Node): void,
unmount(): void,
...
};
declare function createRoot(
container: Element | Document | DocumentFragment,
options?: RootOptions
): Root;
declare function hydrateRoot(
container: Document | Element,
initialChildren: React$Node,
options?: RootOptions
): Root;
}
+18286 -11597
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -73,13 +73,13 @@
"posthog-js": "1.275.2", "posthog-js": "1.275.2",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",
"qr-creator": "^1.0.0", "qr-creator": "^1.0.0",
"react": "16.14.0", "react": "18.2.0",
"react-color": "2.13.8", "react-color": "2.13.8",
"react-dnd": "7.7.0", "react-dnd": "7.7.0",
"react-dnd-html5-backend": "7.7.0", "react-dnd-html5-backend": "7.7.0",
"react-dnd-multi-backend": "3.2.2", "react-dnd-multi-backend": "3.2.2",
"react-dnd-touch-backend": "0.8.3", "react-dnd-touch-backend": "0.8.3",
"react-dom": "16.14.0", "react-dom": "18.2.0",
"react-error-boundary": "^1.2.0", "react-error-boundary": "^1.2.0",
"react-inner-image-zoom": "^3.0.2", "react-inner-image-zoom": "^3.0.2",
"react-instantsearch-hooks": "^6.41.0", "react-instantsearch-hooks": "^6.41.0",
@@ -90,7 +90,7 @@
"react-mosaic-component": "github:4ian/react-mosaic#v3.1.0", "react-mosaic-component": "github:4ian/react-mosaic#v3.1.0",
"react-share": "^4.4.0", "react-share": "^4.4.0",
"react-sortable-hoc": "1.5.0", "react-sortable-hoc": "1.5.0",
"react-test-renderer": "16.14.0", "react-test-renderer": "18.2.0",
"react-virtualized": "9.21.1", "react-virtualized": "9.21.1",
"react-window": "1.8.9", "react-window": "1.8.9",
"recharts": "^2.10.0", "recharts": "^2.10.0",
@@ -103,7 +103,8 @@
"url-search-params": "^1.0.2", "url-search-params": "^1.0.2",
"wavesurfer.js": "^7.8.6", "wavesurfer.js": "^7.8.6",
"xxhashjs": "^0.2.2", "xxhashjs": "^0.2.2",
"yaml": "^2.8.2" "yaml": "^2.8.2",
"@material-ui/styles": "4.11.5"
}, },
"overrides": { "overrides": {
"pixi-spine": { "pixi-spine": {
@@ -114,7 +115,9 @@
"@pixi/mesh": "7.4.2", "@pixi/mesh": "7.4.2",
"@pixi/mesh-extras": "7.4.2", "@pixi/mesh-extras": "7.4.2",
"@pixi/sprite": "7.4.2" "@pixi/sprite": "7.4.2"
} },
"react": "$react",
"react-dom": "$react-dom"
}, },
"scripts": { "scripts": {
"postinstall": "patch-package && cd ../../GDJS && npm install && cd ../newIDE/app && npm run import-resources", "postinstall": "patch-package && cd ../../GDJS && npm install && cd ../newIDE/app && npm run import-resources",
+335
View File
@@ -0,0 +1,335 @@
/**
* Idempotent codemod script to upgrade newIDE/app from React 16 to React 18.
*
* What it does:
* 1. Updates package.json: react, react-dom, react-test-renderer to 18.2.0
* and adds npm overrides so third-party packages accept React 18
* 2. Updates src/index.js: ReactDOM.render() -> createRoot().render()
* 3. Adds Flow type declaration for react-dom/client in flow-libs/react-dom.js
* 4. Renames deprecated lifecycle methods to UNSAFE_ prefix (React 17 requirement):
* - componentWillMount -> UNSAFE_componentWillMount
* - componentWillReceiveProps -> UNSAFE_componentWillReceiveProps
* - componentWillUpdate -> UNSAFE_componentWillUpdate
* 5. Fixes getFillAutomaticallyFunction in InAppTutorialStepDisplayer.js:
* The old native-value-setter + blur/input event trick no longer works in
* React 18. Replaced with focus + select + document.execCommand('insertText')
* which goes through the browser's native input path.
* 6. Fixes ClickAwayListener in GenericExpressionField/index.js:
* MUI v4's ClickAwayListener defaults to onClick/onTouchEnd, which in React 18
* causes the popper to close immediately when opened. Using onMouseDown/onTouchStart
* avoids the race condition with React 18's event delegation.
*
* What it does NOT do (deprecated but still functional in React 18):
* - ReactDOM.findDOMNode: deprecated, warns in StrictMode, but still works.
* Replacing it requires significant refactoring (forwardRef/callback refs).
* - UNSAFE_ lifecycle methods themselves: they work in React 18, just with
* StrictMode warnings. Migrating them to getDerivedStateFromProps/
* componentDidMount is a separate task.
* - e.persist() removal: no calls found in the codebase.
*
* Re-running this script on an already-upgraded codebase is a no-op.
*/
const fs = require('fs');
const path = require('path');
const appRoot = path.resolve(__dirname, '..');
function readFile(relativePath) {
return fs.readFileSync(path.join(appRoot, relativePath), 'utf8');
}
function writeFile(relativePath, content) {
fs.writeFileSync(path.join(appRoot, relativePath), content, 'utf8');
}
// ──────────────────────────────────────────────────────────────────────────────
// 1. Update package.json versions
// ──────────────────────────────────────────────────────────────────────────────
function updatePackageJson() {
const filePath = 'package.json';
const pkg = JSON.parse(readFile(filePath));
let changed = false;
const depUpdates = {
react: '18.2.0',
'react-dom': '18.2.0',
'react-test-renderer': '18.2.0',
};
for (const [dep, version] of Object.entries(depUpdates)) {
if (pkg.dependencies && pkg.dependencies[dep] && pkg.dependencies[dep] !== version) {
pkg.dependencies[dep] = version;
changed = true;
}
}
// Add overrides so that all third-party packages use our React 18,
// even if their peerDependencies only declare React 16/17 support.
// The "$react" / "$react-dom" syntax means "use the version from
// this package.json's own dependencies".
if (!pkg.overrides) pkg.overrides = {};
const requiredOverrides = {
react: '$react',
'react-dom': '$react-dom',
};
for (const [key, value] of Object.entries(requiredOverrides)) {
if (pkg.overrides[key] !== value) {
pkg.overrides[key] = value;
changed = true;
}
}
// @material-ui/styles is a dependency of @material-ui/core but its
// peerDependencies only declare React ^16 || ^17. This causes npm to
// nest it inside @material-ui/core/node_modules instead of hoisting it.
// Since the app code imports directly from '@material-ui/styles', we
// need it hoisted. Adding it as a direct dependency forces hoisting.
if (!pkg.dependencies['@material-ui/styles']) {
pkg.dependencies['@material-ui/styles'] = '4.11.5';
changed = true;
}
if (changed) {
writeFile(filePath, JSON.stringify(pkg, null, 2) + '\n');
console.log('[package.json] Updated react, react-dom, react-test-renderer to 18.2.0 with overrides');
} else {
console.log('[package.json] Already up to date.');
}
}
// ──────────────────────────────────────────────────────────────────────────────
// 2. Update src/index.js to use createRoot from react-dom/client
// ──────────────────────────────────────────────────────────────────────────────
function updateIndexJs() {
const filePath = 'src/index.js';
let content = readFile(filePath);
// Already upgraded?
if (content.includes('react-dom/client')) {
console.log('[src/index.js] Already upgraded to createRoot.');
return;
}
// Replace import
content = content.replace(
"import ReactDOM from 'react-dom';",
"// $FlowFixMe[cannot-resolve-module] - react-dom/client is available in React 18\nimport { createRoot } from 'react-dom/client';"
);
// Replace ReactDOM.render(<Bootstrapper />, rootElement) with createRoot(rootElement).render(<Bootstrapper />)
content = content.replace(
/ReactDOM\.render\(<Bootstrapper \/>, rootElement\)/,
'createRoot(rootElement).render(<Bootstrapper />)'
);
writeFile(filePath, content);
console.log('[src/index.js] Upgraded to createRoot.');
}
// ──────────────────────────────────────────────────────────────────────────────
// 3. Add Flow type declaration for react-dom/client
// ──────────────────────────────────────────────────────────────────────────────
function updateFlowTypes() {
const filePath = 'flow-libs/react-dom.js';
let content = readFile(filePath);
if (content.includes('react-dom/client')) {
console.log('[flow-libs/react-dom.js] Already has react-dom/client declaration.');
return;
}
const declaration = `
// React 18 createRoot API
declare module 'react-dom/client' {
declare type RootOptions = {
onRecoverableError?: (error: mixed) => void,
identifierPrefix?: string,
...
};
declare type Root = {
render(children: React$Node): void,
unmount(): void,
...
};
declare function createRoot(
container: Element | Document | DocumentFragment,
options?: RootOptions
): Root;
declare function hydrateRoot(
container: Document | Element,
initialChildren: React$Node,
options?: RootOptions
): Root;
}
`;
content = content.trimEnd() + '\n' + declaration;
writeFile(filePath, content);
console.log('[flow-libs/react-dom.js] Added react-dom/client declaration.');
}
// ──────────────────────────────────────────────────────────────────────────────
// 4. Rename deprecated lifecycle methods to UNSAFE_ prefix
// React 17 removed the non-prefixed versions of these methods.
// See: https://legacy.reactjs.org/blog/2018/03/27/update-on-async-rendering.html
// ──────────────────────────────────────────────────────────────────────────────
function renameUnsafeLifecycleMethods() {
const srcDir = path.join(appRoot, 'src');
const deprecatedMethods = [
'componentWillMount',
'componentWillReceiveProps',
'componentWillUpdate',
];
// Build regex that matches the method name NOT already preceded by UNSAFE_
// Matches patterns like:
// componentWillMount() {
// componentWillMount () {
// componentWillMount= ... (arrow function assignment, unlikely but safe)
const patterns = deprecatedMethods.map(method => ({
method,
// Negative lookbehind ensures we don't double-prefix UNSAFE_
regex: new RegExp(`(?<!UNSAFE_)\\b(${method})\\b`, 'g'),
replacement: `UNSAFE_${method}`,
}));
let totalReplacements = 0;
function processFile(filePath) {
const ext = path.extname(filePath);
if (ext !== '.js' && ext !== '.jsx' && ext !== '.ts' && ext !== '.tsx') return;
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch {
return;
}
let fileChanged = false;
for (const { method, regex, replacement } of patterns) {
const matches = content.match(regex);
if (matches) {
content = content.replace(regex, replacement);
fileChanged = true;
totalReplacements += matches.length;
const relPath = path.relative(appRoot, filePath);
console.log(` ${relPath}: ${method} -> UNSAFE_${method} (${matches.length}x)`);
}
}
if (fileChanged) {
fs.writeFileSync(filePath, content, 'utf8');
}
}
function walkDir(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
walkDir(fullPath);
} else {
processFile(fullPath);
}
}
}
walkDir(srcDir);
if (totalReplacements === 0) {
console.log('[lifecycle methods] No deprecated lifecycle methods found (already renamed).');
} else {
console.log(`[lifecycle methods] Renamed ${totalReplacements} deprecated lifecycle method(s) to UNSAFE_ prefix.`);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// 5. Fix getFillAutomaticallyFunction for React 18
// The old approach used the native value setter + dispatchEvent('blur'/'input')
// to programmatically fill React-controlled inputs. This no longer triggers
// React 18's onChange. Replace with execCommand('insertText') which goes
// through the browser's native input path.
// ──────────────────────────────────────────────────────────────────────────────
function fixGetFillAutomaticallyFunction() {
const filePath = 'src/InAppTutorial/InAppTutorialStepDisplayer.js';
let content = readFile(filePath);
// Already upgraded?
if (content.includes("document.execCommand('insertText'")) {
console.log('[InAppTutorialStepDisplayer] getFillAutomaticallyFunction already fixed.');
return;
}
// Match the old native-value-setter pattern (handles both 'blur' and 'input' event variants).
// Uses [\s\S]*? instead of [^}]* to handle nested braces (e.g. { bubbles: true }).
const oldPattern =
/const valuePropertyDescriptor = Object\.getOwnPropertyDescriptor\(\s*elementWithId\.constructor\.prototype,\s*'value'\s*\);\s*if \(!valuePropertyDescriptor\) return undefined;\s*const valueSetter = valuePropertyDescriptor\.set;\s*if \(!valueSetter\) return undefined;\s*return \(\) => \{[\s\S]*?\n\s*\};/;
if (!oldPattern.test(content)) {
console.log('[InAppTutorialStepDisplayer] Could not find old getFillAutomaticallyFunction pattern (may need manual update).');
return;
}
const newCode = `return () => {
// Focus the element, select all existing content, then use
// execCommand to insert text. This goes through the browser's
// native input path, which React 18 correctly picks up.
elementWithId.focus();
elementWithId.select();
document.execCommand('insertText', false, valueEquals);
};`;
content = content.replace(oldPattern, newCode);
writeFile(filePath, content);
console.log('[InAppTutorialStepDisplayer] Fixed getFillAutomaticallyFunction for React 18.');
}
// ──────────────────────────────────────────────────────────────────────────────
// 6. Fix ClickAwayListener in GenericExpressionField for React 18
// MUI v4's ClickAwayListener defaults to onClick/onTouchEnd for detecting
// clicks outside. In React 18 (with createRoot), the event delegation moved
// from document to the React root container. This causes the ClickAwayListener's
// document-level handler to fire on the same click that opened the popper,
// immediately closing it. Using onMouseDown/onTouchStart avoids this.
// ──────────────────────────────────────────────────────────────────────────────
function fixClickAwayListenerInGenericExpressionField() {
const filePath = 'src/EventsSheet/ParameterFields/GenericExpressionField/index.js';
let content = readFile(filePath);
// Already upgraded?
if (content.includes('mouseEvent="onMouseDown"')) {
console.log('[GenericExpressionField] ClickAwayListener already fixed.');
return;
}
const oldPattern = '<ClickAwayListener onClickAway={this._handleRequestClose}>';
if (!content.includes(oldPattern)) {
console.log('[GenericExpressionField] Could not find ClickAwayListener pattern (may need manual update).');
return;
}
const newCode = `<ClickAwayListener
onClickAway={this._handleRequestClose}
// Needed since React 18 to avoid what seems to be the immediate closing of the popper.
mouseEvent="onMouseDown"
touchEvent="onTouchStart"
>`;
content = content.replace(oldPattern, newCode);
writeFile(filePath, content);
console.log('[GenericExpressionField] Fixed ClickAwayListener for React 18.');
}
// ──────────────────────────────────────────────────────────────────────────────
// Run all steps
// ──────────────────────────────────────────────────────────────────────────────
console.log('Upgrading newIDE/app to React 18...\n');
updatePackageJson();
updateIndexJs();
updateFlowTypes();
renameUnsafeLifecycleMethods();
fixGetFillAutomaticallyFunction();
fixClickAwayListenerInGenericExpressionField();
console.log('\nDone. Run `npm install` to apply dependency changes.');
+73 -53
View File
@@ -16,6 +16,7 @@ import { useAsyncLazyMemo } from '../Utils/UseLazyMemo';
import { retryIfFailed } from '../Utils/RetryIfFailed'; import { retryIfFailed } from '../Utils/RetryIfFailed';
import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; import PreferencesContext from '../MainFrame/Preferences/PreferencesContext';
import { useInterval } from '../Utils/UseInterval'; import { useInterval } from '../Utils/UseInterval';
import useForceUpdate from '../Utils/UseForceUpdate';
type EditorFunctionCallResultsStorage = {| type EditorFunctionCallResultsStorage = {|
getEditorFunctionCallResults: ( getEditorFunctionCallResults: (
@@ -28,62 +29,71 @@ type EditorFunctionCallResultsStorage = {|
clearEditorFunctionCallResults: (aiRequestId: string) => void, clearEditorFunctionCallResults: (aiRequestId: string) => void,
|}; |};
type EditorFunctionCallResultsPerRequest = {
[aiRequestId: string]: ?Array<EditorFunctionCallResult>,
};
const useEditorFunctionCallResultsStorage = (): EditorFunctionCallResultsStorage => { const useEditorFunctionCallResultsStorage = (): EditorFunctionCallResultsStorage => {
const [ const editorFunctionCallResultsPerRequestRef = React.useRef<EditorFunctionCallResultsPerRequest>(
editorFunctionCallResultsPerRequest, {}
setEditorFunctionCallResultsPerRequest, );
] = React.useState<{ const forceUpdate = useForceUpdate();
[aiRequestId: string]: Array<EditorFunctionCallResult>,
}>({});
return { return {
getEditorFunctionCallResults: React.useCallback( getEditorFunctionCallResults: React.useCallback(
(aiRequestId: string): Array<EditorFunctionCallResult> | null => (aiRequestId: string): Array<EditorFunctionCallResult> | null =>
editorFunctionCallResultsPerRequest[aiRequestId] || null, editorFunctionCallResultsPerRequestRef.current[aiRequestId] || null,
[editorFunctionCallResultsPerRequest] []
), ),
addEditorFunctionCallResults: React.useCallback( addEditorFunctionCallResults: React.useCallback(
( (
aiRequestId: string, aiRequestId: string,
editorFunctionCallResults: EditorFunctionCallResult[] editorFunctionCallResults: EditorFunctionCallResult[]
) => { ) => {
let computedResults: EditorFunctionCallResult[] = []; const previousState = editorFunctionCallResultsPerRequestRef.current;
setEditorFunctionCallResultsPerRequest(prevState => { const existingEditorFunctionCallResults = (
const existingEditorFunctionCallResults = ( previousState[aiRequestId] || []
prevState[aiRequestId] || [] ).filter(existingEditorFunctionCallResult => {
).filter(existingEditorFunctionCallResult => { return !editorFunctionCallResults.some(editorFunctionCallResult => {
return !editorFunctionCallResults.some(editorFunctionCallResult => { return (
return ( editorFunctionCallResult.call_id ===
editorFunctionCallResult.call_id === existingEditorFunctionCallResult.call_id
existingEditorFunctionCallResult.call_id );
);
});
}); });
computedResults = [
...existingEditorFunctionCallResults,
...editorFunctionCallResults,
];
return {
...prevState,
[aiRequestId]: computedResults,
};
}); });
const computedResults = [
...existingEditorFunctionCallResults,
...editorFunctionCallResults,
];
// Store results in the ref so that they are visible immediately
// to any code that calls getEditorFunctionCallResults — even within
// the same async tick. Without this, React 18 automatic batching
// would defer the state update, causing other callbacks (e.g. the
// processing effect) to read stale data and potentially re-process
// the same function calls.
// forceUpdate() schedules a re-render so the UI stays in sync.
editorFunctionCallResultsPerRequestRef.current = {
...editorFunctionCallResultsPerRequestRef.current,
[aiRequestId]: computedResults,
};
forceUpdate();
return computedResults; return computedResults;
}, },
[] [forceUpdate]
), ),
clearEditorFunctionCallResults: React.useCallback((aiRequestId: string) => { clearEditorFunctionCallResults: React.useCallback(
setEditorFunctionCallResultsPerRequest( (aiRequestId: string) => {
editorFunctionCallResultsPerRequest => ({ editorFunctionCallResultsPerRequestRef.current = {
...editorFunctionCallResultsPerRequest, ...editorFunctionCallResultsPerRequestRef.current,
// $FlowFixMe[incompatible-type]
[aiRequestId]: null, [aiRequestId]: null,
}) };
); forceUpdate();
}, []), },
[forceUpdate]
),
}; };
}; };
@@ -269,47 +279,57 @@ export const useAiRequestsStorage = (): AiRequestStorage => {
[profile] [profile]
); );
const [aiRequestSendStates, setAiRequestSendStates] = React.useState<{ // Store send states in a ref so that isSendingAiRequest reads are
// immediately consistent — even within the same async tick.
// Without this, React 18 automatic batching would defer the state
// update from setSendingAiRequest, causing guards (e.g. in
// onSendMessage) to read stale data and potentially trigger
// duplicate API calls.
// forceUpdate() schedules a re-render so the UI stays in sync.
const aiRequestSendStatesRef = React.useRef<{
[string]: AiRequestSendState, [string]: AiRequestSendState,
}>({}); }>({});
const forceUpdateForSendStates = useForceUpdate();
const isSendingAiRequest = React.useCallback( const isSendingAiRequest = React.useCallback(
(aiRequestId: string | null) => (aiRequestId: string | null) =>
!!aiRequestSendStates[aiRequestId || ''] && !!aiRequestSendStatesRef.current[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].isSending, aiRequestSendStatesRef.current[aiRequestId || ''].isSending,
[aiRequestSendStates] []
); );
const getLastSendError = React.useCallback( const getLastSendError = React.useCallback(
(aiRequestId: string | null) => (aiRequestId: string | null) =>
(aiRequestSendStates[aiRequestId || ''] && (aiRequestSendStatesRef.current[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].lastSendError) || aiRequestSendStatesRef.current[aiRequestId || ''].lastSendError) ||
null, null,
[aiRequestSendStates] []
); );
const setSendingAiRequest = React.useCallback( const setSendingAiRequest = React.useCallback(
(aiRequestId: string | null, isSending: boolean) => { (aiRequestId: string | null, isSending: boolean) => {
const aiRequestIdToSet: string = aiRequestId || ''; const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({ aiRequestSendStatesRef.current = {
...aiRequestSendStates, ...aiRequestSendStatesRef.current,
[aiRequestIdToSet]: { [aiRequestIdToSet]: {
isSending, isSending,
lastSendError: null, lastSendError: null,
}, },
})); };
forceUpdateForSendStates();
}, },
[setAiRequestSendStates] [forceUpdateForSendStates]
); );
const setLastSendError = React.useCallback( const setLastSendError = React.useCallback(
(aiRequestId: string | null, lastSendError: ?Error) => { (aiRequestId: string | null, lastSendError: ?Error) => {
const aiRequestIdToSet: string = aiRequestId || ''; const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({ aiRequestSendStatesRef.current = {
...aiRequestSendStates, ...aiRequestSendStatesRef.current,
[aiRequestIdToSet]: { [aiRequestIdToSet]: {
isSending: false, isSending: false,
lastSendError, lastSendError,
}, },
})); };
forceUpdateForSendStates();
}, },
[setAiRequestSendStates] [forceUpdateForSendStates]
); );
return { return {
@@ -617,13 +617,14 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
editorFunctionCallResults: Array<EditorFunctionCallResult>, editorFunctionCallResults: Array<EditorFunctionCallResult>,
mode?: 'chat' | 'agent' | 'orchestrator', mode?: 'chat' | 'agent' | 'orchestrator',
|}) => { |}) => {
if ( if (!profile || !selectedAiRequestId || !selectedAiRequest) return;
!profile ||
!selectedAiRequestId || if (isSendingAiRequest(selectedAiRequestId)) {
!selectedAiRequest || console.info(
isSendingAiRequest(selectedAiRequestId) 'Skipping send for AI request: another send is already in progress.'
) );
return; return;
}
// Read the results from the editor that applied the function calls. // Read the results from the editor that applied the function calls.
// and transform them into the output that will be stored on the AI request. // and transform them into the output that will be stored on the AI request.
@@ -642,8 +643,18 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
// If anything is not finished yet, stop there (we only send all // If anything is not finished yet, stop there (we only send all
// results at once, AI do not support partial results). // results at once, AI do not support partial results).
if (hasUnfinishedResult) return; if (hasUnfinishedResult) {
if (hasFunctionsCallsToProcess) return; console.info(
'Skipping send for AI request: some function call results are not finished yet.'
);
return;
}
if (hasFunctionsCallsToProcess) {
console.info(
'Skipping send for AI request: there are still function calls to process.'
);
return;
}
// If nothing to send, stop there. // If nothing to send, stop there.
if (functionCallOutputs.length === 0 && !userMessage) return; if (functionCallOutputs.length === 0 && !userMessage) return;
@@ -767,6 +778,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
}); });
} }
} catch (error) { } catch (error) {
console.error('Error while sending AI request message:', error);
// TODO: update the label of the button to send again. // TODO: update the label of the button to send again.
setLastSendError(selectedAiRequestId, error); setLastSendError(selectedAiRequestId, error);
setIsSendingUserMessage(false); setIsSendingUserMessage(false);
+92 -47
View File
@@ -161,68 +161,113 @@ export const useProcessFunctionCalls = ({
resourceManagementProps, resourceManagementProps,
}); });
const { generateEvents } = useGenerateEvents({ project }); const { generateEvents } = useGenerateEvents({ project });
// In-memory guard against duplicate processing of the same function call.
//
// The main protection is marking calls as "working" in the ref-backed
// results store (see useEditorFunctionCallResultsStorage). However,
// React 18 can re-run an effect before a forceUpdate() re-render has
// propagated (e.g. StrictMode double-invocations in development, or a
// polling update to selectedAiRequest that recreates onProcessFunctionCalls
// while the previous invocation is still awaiting).
// This Set acts as an immediate, synchronous lock keyed by
// "<requestId>:<callId>" so a call that is already being processed is
// never started a second time.
const inFlightFunctionCallIdsRef = React.useRef<Set<string>>(new Set());
const onProcessFunctionCalls = React.useCallback( const onProcessFunctionCalls = React.useCallback(
async (functionCalls: Array<AiRequestMessageAssistantFunctionCall>) => { async (functionCalls: Array<AiRequestMessageAssistantFunctionCall>) => {
if (!selectedAiRequest || !isReadyToProcessFunctionCalls) return; if (!selectedAiRequest || !isReadyToProcessFunctionCalls) return;
if (selectedAiRequest.status === 'suspended') return; if (selectedAiRequest.status === 'suspended') return;
const functionCallsToProcess = functionCalls.filter(
functionCall =>
!inFlightFunctionCallIdsRef.current.has(
`${selectedAiRequest.id}:${functionCall.call_id}`
)
);
if (functionCallsToProcess.length === 0) {
console.info(
'All function calls are already being processed (in-flight guard), skipping.'
);
return;
}
// Lock these call IDs so concurrent invocations skip them.
functionCallsToProcess.forEach(functionCall => {
inFlightFunctionCallIdsRef.current.add(
`${selectedAiRequest.id}:${functionCall.call_id}`
);
});
addEditorFunctionCallResults( addEditorFunctionCallResults(
selectedAiRequest.id, selectedAiRequest.id,
functionCalls.map(functionCall => ({ functionCallsToProcess.map(functionCall => ({
status: 'working', status: 'working',
call_id: functionCall.call_id, call_id: functionCall.call_id,
})) }))
); );
const { try {
results, const {
createdSceneNames, results,
createdProject, createdSceneNames,
} = await processEditorFunctionCalls({ createdProject,
project, } = await processEditorFunctionCalls({
editorCallbacks, project,
// $FlowFixMe[incompatible-type] editorCallbacks,
toolOptions: selectedAiRequest.toolOptions || null, // $FlowFixMe[incompatible-type]
i18n, toolOptions: selectedAiRequest.toolOptions || null,
functionCalls: functionCalls.map(functionCall => ({ i18n,
name: functionCall.name, functionCalls: functionCallsToProcess.map(functionCall => ({
arguments: functionCall.arguments, name: functionCall.name,
call_id: functionCall.call_id, arguments: functionCall.arguments,
})), call_id: functionCall.call_id,
relatedAiRequestId: selectedAiRequest.id, })),
getRelatedAiRequestLastMessages: () => relatedAiRequestId: selectedAiRequest.id,
getLastMessagesFromAiRequestOutput(selectedAiRequest.output), getRelatedAiRequestLastMessages: () =>
generateEvents, getLastMessagesFromAiRequestOutput(selectedAiRequest.output),
onSceneEventsModifiedOutsideEditor, generateEvents,
onInstancesModifiedOutsideEditor, onSceneEventsModifiedOutsideEditor,
onObjectsModifiedOutsideEditor, onInstancesModifiedOutsideEditor,
onObjectGroupsModifiedOutsideEditor, onObjectsModifiedOutsideEditor,
ensureExtensionInstalled, onObjectGroupsModifiedOutsideEditor,
onWillInstallExtension, ensureExtensionInstalled,
onExtensionInstalled, onWillInstallExtension,
searchAndInstallAsset, onExtensionInstalled,
searchAndInstallResources, searchAndInstallAsset,
}); searchAndInstallResources,
});
// If the request was suspended while we were processing, discard the // If the request was suspended while we were processing, discard the
// results — we don't want to re-populate the cleared results or send // results — we don't want to re-populate the cleared results or send
// anything to a suspended request. // anything to a suspended request.
if (results.some(r => r.status === 'aborted')) { if (results.some(r => r.status === 'aborted')) {
return; console.info(
'Some function call results were aborted (request was likely suspended during processing), discarding all results.'
);
return;
}
const newResults = addEditorFunctionCallResults(
selectedAiRequest.id,
results
);
// We may have processed everything, so try to send the results
// to the backend.
await onSendEditorFunctionCallResults(newResults, {
createdSceneNames,
createdProject,
});
} finally {
// Release the lock so these calls can be retried if needed
// (e.g. after an error or a suspension).
functionCallsToProcess.forEach(functionCall => {
inFlightFunctionCallIdsRef.current.delete(
`${selectedAiRequest.id}:${functionCall.call_id}`
);
});
} }
const newResults = addEditorFunctionCallResults(
selectedAiRequest.id,
results
);
// We may have processed everything, so try to send the results
// to the backend.
await onSendEditorFunctionCallResults(newResults, {
createdSceneNames,
createdProject,
});
}, },
[ [
i18n, i18n,
@@ -666,7 +666,12 @@ export default class ExpressionField extends React.Component<Props, State> {
/> />
</div> </div>
{this._inputElement && this.state.popoverOpen && ( {this._inputElement && this.state.popoverOpen && (
<ClickAwayListener onClickAway={this._handleRequestClose}> <ClickAwayListener
onClickAway={this._handleRequestClose}
// Needed since React 18 to avoid what seems to be the immediate closing of the popper.
mouseEvent="onMouseDown"
touchEvent="onTouchStart"
>
<Popper <Popper
style={popoverStyle} style={popoverStyle}
open={this.state.popoverOpen} open={this.state.popoverOpen}
@@ -138,7 +138,7 @@ export default class ExportLauncher extends Component<Props, State> {
payWithCredits?: boolean, payWithCredits?: boolean,
|}) => Promise<void>; |}) => Promise<void>;
componentWillMount() { UNSAFE_componentWillMount() {
// Fetch limits when the export launcher is opened, to ensure we display the // Fetch limits when the export launcher is opened, to ensure we display the
// latest limits. // latest limits.
this.props.authenticatedUser.onRefreshLimits(); this.props.authenticatedUser.onRefreshLimits();
@@ -312,18 +312,13 @@ function InAppTutorialStepDisplayer({
return undefined; return undefined;
} }
const valuePropertyDescriptor = Object.getOwnPropertyDescriptor(
elementWithId.constructor.prototype,
'value'
);
if (!valuePropertyDescriptor) return undefined;
const valueSetter = valuePropertyDescriptor.set;
if (!valueSetter) return undefined;
return () => { return () => {
valueSetter.call(elementWithId, valueEquals); // Focus the element, select all existing content, then use
// Trigger blur to make sure the value is taken into account // execCommand to insert text. This goes through the browser's
// by the React input. // native input path, which React 18 correctly picks up.
elementWithId.dispatchEvent(new Event('blur', { bubbles: true })); elementWithId.focus();
elementWithId.select();
document.execCommand('insertText', false, valueEquals);
}; };
}, },
[nextStepTrigger, elementWithId] [nextStepTrigger, elementWithId]
+3 -2
View File
@@ -2,7 +2,8 @@
import 'element-closest'; import 'element-closest';
// $FlowFixMe[missing-export] // $FlowFixMe[missing-export]
import React, { Component, type Element } from 'react'; import React, { Component, type Element } from 'react';
import ReactDOM from 'react-dom'; // $FlowFixMe[cannot-resolve-module] - react-dom/client is available in React 18
import { createRoot } from 'react-dom/client';
import Authentication from './Utils/GDevelopServices/Authentication'; import Authentication from './Utils/GDevelopServices/Authentication';
import { import {
sendProgramOpening, sendProgramOpening,
@@ -170,7 +171,7 @@ class Bootstrapper extends Component<{}, State> {
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (rootElement) { if (rootElement) {
GD_STARTUP_TIMES.push(['reactDOMRenderCall', performance.now()]); GD_STARTUP_TIMES.push(['reactDOMRenderCall', performance.now()]);
ReactDOM.render(<Bootstrapper />, rootElement); createRoot(rootElement).render(<Bootstrapper />);
} else console.error('No root element defined in index.html'); } else console.error('No root element defined in index.html');
registerServiceWorker(); registerServiceWorker();