mirror of
https://github.com/Heretek-AI/GDevelop-Heretek.git
synced 2026-10-11 21:09:52 +00:00
@@ -250,7 +250,7 @@ blocks:
|
||||
(cd GDJS && git checkout package-lock.json && npm i)
|
||||
- cd newIDE/app
|
||||
- npm run postinstall
|
||||
- npm run analyze-test-coverage
|
||||
- npm run test
|
||||
|
||||
- name: GDJS tests
|
||||
commands:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// This file customizes webpack configuration for react-app-rewired.
|
||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
||||
|
||||
module.exports = {
|
||||
webpack: function override(config, env) {
|
||||
@@ -16,12 +17,16 @@ module.exports = {
|
||||
// but it is not a blocking issue, so we ignore them.
|
||||
config.ignoreWarnings = [/Failed to parse source map/];
|
||||
|
||||
config.resolve.plugins = config.resolve.plugins.filter(
|
||||
(plugin) => !(plugin instanceof ModuleScopePlugin)
|
||||
);
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
jest: function(config) {
|
||||
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;
|
||||
|
||||
Vendored
+23
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+18286
-11597
File diff suppressed because it is too large
Load Diff
@@ -73,13 +73,13 @@
|
||||
"posthog-js": "1.275.2",
|
||||
"prop-types": "^15.5.10",
|
||||
"qr-creator": "^1.0.0",
|
||||
"react": "16.14.0",
|
||||
"react": "18.2.0",
|
||||
"react-color": "2.13.8",
|
||||
"react-dnd": "7.7.0",
|
||||
"react-dnd-html5-backend": "7.7.0",
|
||||
"react-dnd-multi-backend": "3.2.2",
|
||||
"react-dnd-touch-backend": "0.8.3",
|
||||
"react-dom": "16.14.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^1.2.0",
|
||||
"react-inner-image-zoom": "^3.0.2",
|
||||
"react-instantsearch-hooks": "^6.41.0",
|
||||
@@ -90,7 +90,7 @@
|
||||
"react-mosaic-component": "github:4ian/react-mosaic#v3.1.0",
|
||||
"react-share": "^4.4.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-window": "1.8.9",
|
||||
"recharts": "^2.10.0",
|
||||
@@ -103,7 +103,8 @@
|
||||
"url-search-params": "^1.0.2",
|
||||
"wavesurfer.js": "^7.8.6",
|
||||
"xxhashjs": "^0.2.2",
|
||||
"yaml": "^2.8.2"
|
||||
"yaml": "^2.8.2",
|
||||
"@material-ui/styles": "4.11.5"
|
||||
},
|
||||
"overrides": {
|
||||
"pixi-spine": {
|
||||
@@ -114,7 +115,9 @@
|
||||
"@pixi/mesh": "7.4.2",
|
||||
"@pixi/mesh-extras": "7.4.2",
|
||||
"@pixi/sprite": "7.4.2"
|
||||
}
|
||||
},
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "patch-package && cd ../../GDJS && npm install && cd ../newIDE/app && npm run import-resources",
|
||||
|
||||
@@ -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.');
|
||||
@@ -16,6 +16,7 @@ import { useAsyncLazyMemo } from '../Utils/UseLazyMemo';
|
||||
import { retryIfFailed } from '../Utils/RetryIfFailed';
|
||||
import PreferencesContext from '../MainFrame/Preferences/PreferencesContext';
|
||||
import { useInterval } from '../Utils/UseInterval';
|
||||
import useForceUpdate from '../Utils/UseForceUpdate';
|
||||
|
||||
type EditorFunctionCallResultsStorage = {|
|
||||
getEditorFunctionCallResults: (
|
||||
@@ -28,62 +29,71 @@ type EditorFunctionCallResultsStorage = {|
|
||||
clearEditorFunctionCallResults: (aiRequestId: string) => void,
|
||||
|};
|
||||
|
||||
type EditorFunctionCallResultsPerRequest = {
|
||||
[aiRequestId: string]: ?Array<EditorFunctionCallResult>,
|
||||
};
|
||||
|
||||
const useEditorFunctionCallResultsStorage = (): EditorFunctionCallResultsStorage => {
|
||||
const [
|
||||
editorFunctionCallResultsPerRequest,
|
||||
setEditorFunctionCallResultsPerRequest,
|
||||
] = React.useState<{
|
||||
[aiRequestId: string]: Array<EditorFunctionCallResult>,
|
||||
}>({});
|
||||
const editorFunctionCallResultsPerRequestRef = React.useRef<EditorFunctionCallResultsPerRequest>(
|
||||
{}
|
||||
);
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
return {
|
||||
getEditorFunctionCallResults: React.useCallback(
|
||||
(aiRequestId: string): Array<EditorFunctionCallResult> | null =>
|
||||
editorFunctionCallResultsPerRequest[aiRequestId] || null,
|
||||
[editorFunctionCallResultsPerRequest]
|
||||
editorFunctionCallResultsPerRequestRef.current[aiRequestId] || null,
|
||||
[]
|
||||
),
|
||||
addEditorFunctionCallResults: React.useCallback(
|
||||
(
|
||||
aiRequestId: string,
|
||||
editorFunctionCallResults: EditorFunctionCallResult[]
|
||||
) => {
|
||||
let computedResults: EditorFunctionCallResult[] = [];
|
||||
setEditorFunctionCallResultsPerRequest(prevState => {
|
||||
const existingEditorFunctionCallResults = (
|
||||
prevState[aiRequestId] || []
|
||||
).filter(existingEditorFunctionCallResult => {
|
||||
return !editorFunctionCallResults.some(editorFunctionCallResult => {
|
||||
return (
|
||||
editorFunctionCallResult.call_id ===
|
||||
existingEditorFunctionCallResult.call_id
|
||||
);
|
||||
});
|
||||
const previousState = editorFunctionCallResultsPerRequestRef.current;
|
||||
const existingEditorFunctionCallResults = (
|
||||
previousState[aiRequestId] || []
|
||||
).filter(existingEditorFunctionCallResult => {
|
||||
return !editorFunctionCallResults.some(editorFunctionCallResult => {
|
||||
return (
|
||||
editorFunctionCallResult.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;
|
||||
},
|
||||
[]
|
||||
[forceUpdate]
|
||||
),
|
||||
clearEditorFunctionCallResults: React.useCallback((aiRequestId: string) => {
|
||||
setEditorFunctionCallResultsPerRequest(
|
||||
editorFunctionCallResultsPerRequest => ({
|
||||
...editorFunctionCallResultsPerRequest,
|
||||
// $FlowFixMe[incompatible-type]
|
||||
clearEditorFunctionCallResults: React.useCallback(
|
||||
(aiRequestId: string) => {
|
||||
editorFunctionCallResultsPerRequestRef.current = {
|
||||
...editorFunctionCallResultsPerRequestRef.current,
|
||||
[aiRequestId]: null,
|
||||
})
|
||||
);
|
||||
}, []),
|
||||
};
|
||||
forceUpdate();
|
||||
},
|
||||
[forceUpdate]
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -269,47 +279,57 @@ export const useAiRequestsStorage = (): AiRequestStorage => {
|
||||
[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,
|
||||
}>({});
|
||||
const forceUpdateForSendStates = useForceUpdate();
|
||||
const isSendingAiRequest = React.useCallback(
|
||||
(aiRequestId: string | null) =>
|
||||
!!aiRequestSendStates[aiRequestId || ''] &&
|
||||
aiRequestSendStates[aiRequestId || ''].isSending,
|
||||
[aiRequestSendStates]
|
||||
!!aiRequestSendStatesRef.current[aiRequestId || ''] &&
|
||||
aiRequestSendStatesRef.current[aiRequestId || ''].isSending,
|
||||
[]
|
||||
);
|
||||
const getLastSendError = React.useCallback(
|
||||
(aiRequestId: string | null) =>
|
||||
(aiRequestSendStates[aiRequestId || ''] &&
|
||||
aiRequestSendStates[aiRequestId || ''].lastSendError) ||
|
||||
(aiRequestSendStatesRef.current[aiRequestId || ''] &&
|
||||
aiRequestSendStatesRef.current[aiRequestId || ''].lastSendError) ||
|
||||
null,
|
||||
[aiRequestSendStates]
|
||||
[]
|
||||
);
|
||||
const setSendingAiRequest = React.useCallback(
|
||||
(aiRequestId: string | null, isSending: boolean) => {
|
||||
const aiRequestIdToSet: string = aiRequestId || '';
|
||||
setAiRequestSendStates(aiRequestSendStates => ({
|
||||
...aiRequestSendStates,
|
||||
aiRequestSendStatesRef.current = {
|
||||
...aiRequestSendStatesRef.current,
|
||||
[aiRequestIdToSet]: {
|
||||
isSending,
|
||||
lastSendError: null,
|
||||
},
|
||||
}));
|
||||
};
|
||||
forceUpdateForSendStates();
|
||||
},
|
||||
[setAiRequestSendStates]
|
||||
[forceUpdateForSendStates]
|
||||
);
|
||||
const setLastSendError = React.useCallback(
|
||||
(aiRequestId: string | null, lastSendError: ?Error) => {
|
||||
const aiRequestIdToSet: string = aiRequestId || '';
|
||||
setAiRequestSendStates(aiRequestSendStates => ({
|
||||
...aiRequestSendStates,
|
||||
aiRequestSendStatesRef.current = {
|
||||
...aiRequestSendStatesRef.current,
|
||||
[aiRequestIdToSet]: {
|
||||
isSending: false,
|
||||
lastSendError,
|
||||
},
|
||||
}));
|
||||
};
|
||||
forceUpdateForSendStates();
|
||||
},
|
||||
[setAiRequestSendStates]
|
||||
[forceUpdateForSendStates]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -617,13 +617,14 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
|
||||
editorFunctionCallResults: Array<EditorFunctionCallResult>,
|
||||
mode?: 'chat' | 'agent' | 'orchestrator',
|
||||
|}) => {
|
||||
if (
|
||||
!profile ||
|
||||
!selectedAiRequestId ||
|
||||
!selectedAiRequest ||
|
||||
isSendingAiRequest(selectedAiRequestId)
|
||||
)
|
||||
if (!profile || !selectedAiRequestId || !selectedAiRequest) return;
|
||||
|
||||
if (isSendingAiRequest(selectedAiRequestId)) {
|
||||
console.info(
|
||||
'Skipping send for AI request: another send is already in progress.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -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
|
||||
// results at once, AI do not support partial results).
|
||||
if (hasUnfinishedResult) return;
|
||||
if (hasFunctionsCallsToProcess) return;
|
||||
if (hasUnfinishedResult) {
|
||||
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 (functionCallOutputs.length === 0 && !userMessage) return;
|
||||
@@ -767,6 +778,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error while sending AI request message:', error);
|
||||
// TODO: update the label of the button to send again.
|
||||
setLastSendError(selectedAiRequestId, error);
|
||||
setIsSendingUserMessage(false);
|
||||
|
||||
@@ -161,68 +161,113 @@ export const useProcessFunctionCalls = ({
|
||||
resourceManagementProps,
|
||||
});
|
||||
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(
|
||||
async (functionCalls: Array<AiRequestMessageAssistantFunctionCall>) => {
|
||||
if (!selectedAiRequest || !isReadyToProcessFunctionCalls) 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(
|
||||
selectedAiRequest.id,
|
||||
functionCalls.map(functionCall => ({
|
||||
functionCallsToProcess.map(functionCall => ({
|
||||
status: 'working',
|
||||
call_id: functionCall.call_id,
|
||||
}))
|
||||
);
|
||||
|
||||
const {
|
||||
results,
|
||||
createdSceneNames,
|
||||
createdProject,
|
||||
} = await processEditorFunctionCalls({
|
||||
project,
|
||||
editorCallbacks,
|
||||
// $FlowFixMe[incompatible-type]
|
||||
toolOptions: selectedAiRequest.toolOptions || null,
|
||||
i18n,
|
||||
functionCalls: functionCalls.map(functionCall => ({
|
||||
name: functionCall.name,
|
||||
arguments: functionCall.arguments,
|
||||
call_id: functionCall.call_id,
|
||||
})),
|
||||
relatedAiRequestId: selectedAiRequest.id,
|
||||
getRelatedAiRequestLastMessages: () =>
|
||||
getLastMessagesFromAiRequestOutput(selectedAiRequest.output),
|
||||
generateEvents,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
onInstancesModifiedOutsideEditor,
|
||||
onObjectsModifiedOutsideEditor,
|
||||
onObjectGroupsModifiedOutsideEditor,
|
||||
ensureExtensionInstalled,
|
||||
onWillInstallExtension,
|
||||
onExtensionInstalled,
|
||||
searchAndInstallAsset,
|
||||
searchAndInstallResources,
|
||||
});
|
||||
try {
|
||||
const {
|
||||
results,
|
||||
createdSceneNames,
|
||||
createdProject,
|
||||
} = await processEditorFunctionCalls({
|
||||
project,
|
||||
editorCallbacks,
|
||||
// $FlowFixMe[incompatible-type]
|
||||
toolOptions: selectedAiRequest.toolOptions || null,
|
||||
i18n,
|
||||
functionCalls: functionCallsToProcess.map(functionCall => ({
|
||||
name: functionCall.name,
|
||||
arguments: functionCall.arguments,
|
||||
call_id: functionCall.call_id,
|
||||
})),
|
||||
relatedAiRequestId: selectedAiRequest.id,
|
||||
getRelatedAiRequestLastMessages: () =>
|
||||
getLastMessagesFromAiRequestOutput(selectedAiRequest.output),
|
||||
generateEvents,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
onInstancesModifiedOutsideEditor,
|
||||
onObjectsModifiedOutsideEditor,
|
||||
onObjectGroupsModifiedOutsideEditor,
|
||||
ensureExtensionInstalled,
|
||||
onWillInstallExtension,
|
||||
onExtensionInstalled,
|
||||
searchAndInstallAsset,
|
||||
searchAndInstallResources,
|
||||
});
|
||||
|
||||
// If the request was suspended while we were processing, discard the
|
||||
// results — we don't want to re-populate the cleared results or send
|
||||
// anything to a suspended request.
|
||||
if (results.some(r => r.status === 'aborted')) {
|
||||
return;
|
||||
// If the request was suspended while we were processing, discard the
|
||||
// results — we don't want to re-populate the cleared results or send
|
||||
// anything to a suspended request.
|
||||
if (results.some(r => r.status === 'aborted')) {
|
||||
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,
|
||||
|
||||
@@ -666,7 +666,12 @@ export default class ExpressionField extends React.Component<Props, State> {
|
||||
/>
|
||||
</div>
|
||||
{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
|
||||
style={popoverStyle}
|
||||
open={this.state.popoverOpen}
|
||||
|
||||
@@ -138,7 +138,7 @@ export default class ExportLauncher extends Component<Props, State> {
|
||||
payWithCredits?: boolean,
|
||||
|}) => Promise<void>;
|
||||
|
||||
componentWillMount() {
|
||||
UNSAFE_componentWillMount() {
|
||||
// Fetch limits when the export launcher is opened, to ensure we display the
|
||||
// latest limits.
|
||||
this.props.authenticatedUser.onRefreshLimits();
|
||||
|
||||
@@ -312,18 +312,13 @@ function InAppTutorialStepDisplayer({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const valuePropertyDescriptor = Object.getOwnPropertyDescriptor(
|
||||
elementWithId.constructor.prototype,
|
||||
'value'
|
||||
);
|
||||
if (!valuePropertyDescriptor) return undefined;
|
||||
const valueSetter = valuePropertyDescriptor.set;
|
||||
if (!valueSetter) return undefined;
|
||||
return () => {
|
||||
valueSetter.call(elementWithId, valueEquals);
|
||||
// Trigger blur to make sure the value is taken into account
|
||||
// by the React input.
|
||||
elementWithId.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||
// 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);
|
||||
};
|
||||
},
|
||||
[nextStepTrigger, elementWithId]
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import 'element-closest';
|
||||
// $FlowFixMe[missing-export]
|
||||
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 {
|
||||
sendProgramOpening,
|
||||
@@ -170,7 +171,7 @@ class Bootstrapper extends Component<{}, State> {
|
||||
const rootElement = document.getElementById('root');
|
||||
if (rootElement) {
|
||||
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');
|
||||
|
||||
registerServiceWorker();
|
||||
|
||||
Reference in New Issue
Block a user