From 1a67c33ec07c54158550c5d8478da76b481d8e1b Mon Sep 17 00:00:00 2001 From: JustDooooIt <844730658@qq.com> Date: Mon, 19 Oct 2026 23:07:39 +0800 Subject: [PATCH 1/9] fix: bind TypeScript scripts when constructing custom Resources --- .../gode/runtime/typescript_compiler.js | 14 +++++ .../tests/runtime_constructed_resource.ts | 20 +++++++ .../scripts/tests/runtime_integration_test.ts | 41 ++++++++++++++ include/runtime/value_convert.h | 1 + include/script/script_instance.h | 3 ++ include/script/typescript_script.h | 1 + src/runtime/value_convert.cpp | 53 +++++++++++++++++++ src/script/script_instance.cpp | 47 +++++++++++++++- src/script/script_instance_info.cpp | 13 ++--- src/script/typescript_script_runtime.cpp | 6 +++ test/test_typescript_compiler_js.py | 24 +++++++++ 11 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 example/scripts/tests/runtime_constructed_resource.ts diff --git a/example/addons/gode/runtime/typescript_compiler.js b/example/addons/gode/runtime/typescript_compiler.js index dbfe81a..21f4219 100644 --- a/example/addons/gode/runtime/typescript_compiler.js +++ b/example/addons/gode/runtime/typescript_compiler.js @@ -835,6 +835,20 @@ } function visit(node) { + // Record the source on the default class, including anonymous classes. + // The native Resource constructor uses this to attach the script to + // the existing JS object instead of invoking its constructor again. + if (tsApi.isClassDeclaration(node) && node.heritageClauses && + node.modifiers && node.modifiers.some(m => m.kind === tsApi.SyntaxKind.DefaultKeyword)) { + const marker = factory.createPropertyDeclaration( + [factory.createModifier(tsApi.SyntaxKind.StaticKeyword)], + factory.createComputedPropertyName(factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier("Symbol"), "for"), + undefined, [factory.createStringLiteral("__gode.script_path__")])), + undefined, undefined, factory.createStringLiteral(sourcePath)); + node = factory.updateClassDeclaration(node, node.modifiers, node.name, + node.typeParameters, node.heritageClauses, [...node.members, marker]); + } if (tsApi.isImportDeclaration(node) && node.moduleSpecifier) { const moduleSpecifier = rewriteModuleSpecifier(node.moduleSpecifier); if (moduleSpecifier !== node.moduleSpecifier) { diff --git a/example/scripts/tests/runtime_constructed_resource.ts b/example/scripts/tests/runtime_constructed_resource.ts new file mode 100644 index 0000000..db8df50 --- /dev/null +++ b/example/scripts/tests/runtime_constructed_resource.ts @@ -0,0 +1,20 @@ +import { Resource, Vector2 } from "godot"; + +export default class RuntimeConstructedResource extends Resource { + static constructions: number = 0; + + @Export() + label: string = "default"; + + @Export() + position: Vector2 = new Vector2(1, 2); + + @Export() + children: RuntimeConstructedResource[] = []; + + constructor(label: string = "default") { + super(); + RuntimeConstructedResource.constructions++; + this.label = label; + } +} diff --git a/example/scripts/tests/runtime_integration_test.ts b/example/scripts/tests/runtime_integration_test.ts index bcb75a7..ba2b70c 100644 --- a/example/scripts/tests/runtime_integration_test.ts +++ b/example/scripts/tests/runtime_integration_test.ts @@ -1,3 +1,4 @@ +import RuntimeConstructedResource from "./runtime_constructed_resource.js"; import nodeAssert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; @@ -533,6 +534,46 @@ class RuntimeIntegrationTest extends RuntimeSameFileExportBase { nodeAssert.equal(Number(staticRangeNamespaceProperty.hint), PROPERTY_HINT_RANGE); nodeAssert.equal(String(staticRangeNamespaceProperty.hint_string), "0,4,1"); + const beforeConstructions = RuntimeConstructedResource.constructions; + const directResource = new RuntimeConstructedResource("direct"); + nodeAssert.equal(RuntimeConstructedResource.constructions, beforeConstructions + 1); + assert(directResource.get_script() !== null, "new Resource subclass has no script"); + nodeAssert.equal(directResource.get("label"), "direct"); + directResource.set("label", "from Godot"); + nodeAssert.equal(directResource.label, "from Godot"); + directResource.label = "saved direct"; + directResource.position = new Vector2(6.5, 12.5); + directResource.children = [new RuntimeConstructedResource("nested child")]; + const directPath = "user://gode_constructed_resource.tres"; + nodeAssert.equal(ResourceSaver.save(directResource, directPath), GODOT_OK); + const restoredDirect = ResourceLoader.load(directPath, "", ResourceLoader.CACHE_MODE_IGNORE) as RuntimeConstructedResource; + assert(restoredDirect !== directResource, "round trip reused original Resource"); + nodeAssert.equal(restoredDirect.get("label"), "saved direct"); + const restoredPosition = restoredDirect.get("position") as Vector2; + nodeAssert.equal(restoredPosition.x, 6.5); + nodeAssert.equal(restoredPosition.y, 12.5); + const restoredChildren = restoredDirect.get("children") as RuntimeConstructedResource[]; + nodeAssert.equal(restoredChildren.length, 1); + nodeAssert.equal(restoredChildren[0].get("label"), "nested child"); + + const nativeResourceOwner = new GDArray(); + const adoptedId = (() => { + const child = new RuntimeConstructedResource("retained by Godot"); + nativeResourceOwner.append(child); + return child.get_instance_id(); + })(); + await waitForEventLoopTurn(); + forceGarbageCollection(); + await waitForEventLoopTurn(); + nodeAssert.equal((nativeResourceOwner.get(0) as Resource).get("label"), "retained by Godot"); + nativeResourceOwner.clear(); + await waitForEventLoopTurn(); + for (let i = 0; i < 3; i++) { + forceGarbageCollection(); + await waitForEventLoopTurn(); + } + nodeAssert.equal(GD.instance_from_id(BigInt.asIntN(64, BigInt(adoptedId))), null, "constructed Resource retained a JS/native reference cycle"); + const exportedResource = new Resource(); exportedResource.resource_name = "PersistentRuntimeResource"; this.resource_slot = exportedResource; diff --git a/include/runtime/value_convert.h b/include/runtime/value_convert.h index c7b7208..0cb4da7 100644 --- a/include/runtime/value_convert.h +++ b/include/runtime/value_convert.h @@ -73,6 +73,7 @@ class ScriptInstanceOwnerScope { void register_class(const std::string &name, const std::string &godot_class_name, Napi::FunctionReference *ref, UnwrapFunc unwrapper, WrapFunc wrapper, CreateFunc creator); godot::Object *consume_script_instance_owner(); +Napi::Object consume_script_instance_object(godot::Object *owner); godot::Object *unwrap_godot_object(const Napi::Object &value); void register_godot_instance(godot::Object *obj, Napi::Object js_obj); Napi::Value wrap_godot_object(Napi::Env env, godot::Object *obj, const std::string ®istered_class_name = ""); diff --git a/include/script/script_instance.h b/include/script/script_instance.h index 818bfde..690fa6a 100644 --- a/include/script/script_instance.h +++ b/include/script/script_instance.h @@ -21,6 +21,7 @@ class ScriptInstance { godot::Object *owner = nullptr; Napi::ObjectReference js_instance; bool placeholder = false; + bool js_reference_strong = false; godot::HashMap placeholder_properties; // Keep exported Godot-object values anchored while a script instance owns them. mutable godot::HashMap property_storage; @@ -36,6 +37,7 @@ class ScriptInstance { private: void register_script_signals(); + void retain_js_instance(const Napi::Object &p_instance); bool bind_script_signals_to_instance(const Napi::Object &p_instance, const std::string &p_context); void notification_bind(Napi::Object instance, int32_t p_what, bool p_reversed); void store_property_value_for_lifetime(const godot::StringName &p_name, const godot::Variant &p_value) const; @@ -46,6 +48,7 @@ class ScriptInstance { godot::Object *get_owner() const; bool is_placeholder() const; + void update_js_reference(); bool is_runtime_instance_valid() const; void release_runtime_state(); diff --git a/include/script/typescript_script.h b/include/script/typescript_script.h index 7399c83..88172ec 100644 --- a/include/script/typescript_script.h +++ b/include/script/typescript_script.h @@ -64,6 +64,7 @@ class TypeScriptScript : public godot::ScriptExtension { bool compile() const; godot::Error reload_source_code(const godot::String &p_code, bool p_keep_state, bool p_force_dirty = false); void release_runtime_state(); + void cache_constructed_class(const Napi::Function &p_class); bool has_static_method(const godot::StringName &p_method) const; int32_t get_static_method_argument_count(const godot::StringName &p_method) const; godot::Variant call_static(const godot::Variant **p_args, GDExtensionInt p_argcount, GDExtensionCallError &r_error); diff --git a/src/runtime/value_convert.cpp b/src/runtime/value_convert.cpp index 3ccb75c..4dda7d3 100644 --- a/src/runtime/value_convert.cpp +++ b/src/runtime/value_convert.cpp @@ -1,4 +1,6 @@ #include "runtime/value_convert.h" +#include "script/typescript_script.h" +#include #include #include @@ -110,6 +112,18 @@ static std::vector class_order; static std::unordered_map object_cache; static thread_local godot::Object *script_instance_owner = nullptr; +static thread_local godot::Object *adopted_owner = nullptr; +static thread_local Napi::Object *adopted_object = nullptr; + +Napi::Object consume_script_instance_object(godot::Object *owner) { + if (owner != adopted_owner || !adopted_object) { + return Napi::Object(); + } + Napi::Object result = *adopted_object; + adopted_object = nullptr; + return result; +} + constexpr const char *GODOT_OBJECT_ID_SYMBOL = "__gode.godot_object_id__"; constexpr const char *GODOT_OBJECT_PTR_SYMBOL = "__gode.godot_object_ptr__"; constexpr double JS_MAX_SAFE_INTEGER = 9007199254740991.0; @@ -498,6 +512,45 @@ void register_godot_instance(godot::Object *obj, Napi::Object js_obj) { js_obj.Set(Napi::Symbol::For(env, GODOT_OBJECT_ID_SYMBOL), Napi::BigInt::New(env, id)); js_obj.Set(Napi::Symbol::For(env, GODOT_OBJECT_PTR_SYMBOL), Napi::External::New(env, obj)); object_cache[id] = Napi::Weak(js_obj); + // Only Resource subclasses with their own source marker are script classes. + // Engine-created wrappers already have a script, and must never be rebound. + auto *resource = godot::Object::cast_to(obj); + if (!resource || resource->get_script().get_type() != godot::Variant::NIL) { + return; + } + Napi::Value ctor_value = js_obj.Get("constructor"); + if (!ctor_value.IsFunction()) { + return; + } + Napi::Object ctor = ctor_value.As(); + Napi::Symbol marker = Napi::Symbol::For(env, "__gode.script_path__"); + if (!ctor.HasOwnProperty(marker)) { + return; + } + Napi::Value path = ctor.Get(marker); + if (!path.IsString()) { + return; + } + godot::Ref script = godot::ResourceLoader::get_singleton()->load( + godot::String::utf8(path.As().Utf8Value().c_str())); + if (script.is_null()) { + Napi::Error::New(env, "Unable to load TypeScript Resource script").ThrowAsJavaScriptException(); + return; + } + // This module is already evaluated. Cache its actual constructor so a + // subsequent ResourceLoader round trip need not re-enter the module loader. + script->cache_constructed_class(ctor_value.As()); + struct AdoptionScope { + godot::Object *previous_owner = adopted_owner; + Napi::Object *previous_object = adopted_object; + ~AdoptionScope() { adopted_owner = previous_owner; adopted_object = previous_object; } + } scope; + adopted_owner = obj; + adopted_object = &js_obj; + resource->set_script(script); + if (!script->_instance_has(obj)) { + Napi::Error::New(env, "Unable to attach TypeScript Resource script").ThrowAsJavaScriptException(); + } } void clear_godot_instance_cache() { diff --git a/src/script/script_instance.cpp b/src/script/script_instance.cpp index 3989204..9f67e84 100644 --- a/src/script/script_instance.cpp +++ b/src/script/script_instance.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -140,6 +141,18 @@ ScriptInstance::ScriptInstance(const Ref &p_script, Object *p_ return; } + Napi::Object existing = consume_script_instance_object(owner); + if (!existing.IsEmpty()) { + register_script_signals(); + if (!bind_script_signals_to_instance(existing, "JS Resource adoption")) { + return; + } + // The wrapper owns the initial native reference. Keep a weak JS + // reference until another Godot owner retains the Resource. + retain_js_instance(existing); + return; + } + // This can compile TypeScript; keep it outside the instance V8 scope to avoid lock inversion. if (!script->ensure_default_class_loaded()) { return; @@ -185,8 +198,38 @@ ScriptInstance::ScriptInstance(const Ref &p_script, Object *p_ // Register signals before creating the runtime module instance. register_script_signals(); - js_instance = Napi::Persistent(instance); + retain_js_instance(instance); + } +} + +void ScriptInstance::retain_js_instance(const Napi::Object &p_instance) { + if (Object::cast_to(owner)) { + js_instance = Napi::Weak(p_instance); + js_reference_strong = false; + update_js_reference(); + } else { + js_instance = Napi::Persistent(p_instance); + } +} + +void ScriptInstance::update_js_reference() { + if (js_instance.IsEmpty() || !NodeRuntime::is_running()) { + return; + } + auto *ref = Object::cast_to(owner); + const bool strong = ref && ref->get_reference_count() > 1; + if (strong == js_reference_strong) { + return; + } + v8::Locker locker(NodeRuntime::isolate); + v8::Isolate::Scope isolate_scope(NodeRuntime::isolate); + Napi::HandleScope scope(js_instance.Env()); + if (strong) { + js_instance.Ref(); + } else { + js_instance.Unref(); } + js_reference_strong = strong; } ScriptInstance::~ScriptInstance() { @@ -358,7 +401,7 @@ void ScriptInstance::reload(bool p_keep_state) { } } - js_instance = Napi::Persistent(instance); + retain_js_instance(instance); } bool ScriptInstance::set(const StringName &p_name, const Variant &p_value) { diff --git a/src/script/script_instance_info.cpp b/src/script/script_instance_info.cpp index 57eb9b8..2b83fe5 100644 --- a/src/script/script_instance_info.cpp +++ b/src/script/script_instance_info.cpp @@ -243,22 +243,17 @@ static void script_instance_to_string(GDExtensionScriptInstanceDataPtr p_instanc static void script_instance_refcount_incremented(GDExtensionScriptInstanceDataPtr p_instance) { ScriptInstance *instance = cast_instance(p_instance); if (instance) { - RefCounted *ref_counted = Object::cast_to(instance->get_owner()); - if (ref_counted) { - ref_counted->reference(); - } + instance->update_js_reference(); } } static GDExtensionBool script_instance_refcount_decremented(GDExtensionScriptInstanceDataPtr p_instance) { ScriptInstance *instance = cast_instance(p_instance); if (instance) { - RefCounted *ref_counted = Object::cast_to(instance->get_owner()); - if (ref_counted) { - return ref_counted->unreference(); - } - return false; + instance->update_js_reference(); } + // Godot already adjusted the native reference count. Adjust only the JS + // handle above; referencing the native owner here creates a lifetime cycle. return true; } diff --git a/src/script/typescript_script_runtime.cpp b/src/script/typescript_script_runtime.cpp index 284a05d..156ae0b 100644 --- a/src/script/typescript_script_runtime.cpp +++ b/src/script/typescript_script_runtime.cpp @@ -70,6 +70,12 @@ void TypeScriptScript::release_all_runtime_state() { } } +void TypeScriptScript::cache_constructed_class(const Napi::Function &p_class) { + if (default_class.IsEmpty()) { + default_class = Napi::Persistent(p_class); + } +} + void TypeScriptScript::release_runtime_state() { std::vector active_instances; active_instances.reserve(instances.size()); diff --git a/test/test_typescript_compiler_js.py b/test/test_typescript_compiler_js.py index 5b9c1a6..d1749b0 100644 --- a/test/test_typescript_compiler_js.py +++ b/test/test_typescript_compiler_js.py @@ -95,6 +95,30 @@ def run_compiler_fixture(self, test_body: str): f"stderr:\n{completed.stderr}" ) + def test_default_class_records_source_for_native_resource_construction(self): + self.run_compiler_fixture( + r""" + const files = new Map([ + ["res://tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2022", types: [] } })], + ["res://scripts/named.ts", "class Base {} export default class Named extends Base { value = 7; }"], + ["res://scripts/anonymous.ts", "class Base {} export default class extends Base { value = 9; }"] + ]); + const result = runCompiler(files); + assertOk(result); + for (const output of result.outputs) { + const code = output.code.replace("export default class", "return class"); + const cls = new Function(code)(); + const marker = Symbol.for("__gode.script_path__"); + if (!Object.hasOwn(cls, marker) || cls[marker] !== output.source) { + throw new Error("Default class has no own source metadata: " + output.source); + } + if (new cls().value !== (output.source.endsWith("named.ts") ? 7 : 9)) { + throw new Error("Source metadata changed instance initialization"); + } + } + """ + ) + def test_tsconfig_include_exclude_controls_project_roots_and_imported_outputs(self): self.run_compiler_fixture( r""" From fe4c1c14de78208f811590c32cb21dc53cb4b61f Mon Sep 17 00:00:00 2001 From: JustDooooIt <844730658@qq.com> Date: Wed, 21 Oct 2026 02:40:48 +0800 Subject: [PATCH 2/9] feat: add resource lifetime verification and runtime resource class --- .../tests/runtime_resource_lifetime.ts | 7 +++++ example/scripts/tests/tests_runner.gd | 27 +++++++++++++++++++ src/runtime/value_convert.cpp | 14 ++++++++++ 3 files changed, 48 insertions(+) create mode 100644 example/scripts/tests/runtime_resource_lifetime.ts diff --git a/example/scripts/tests/runtime_resource_lifetime.ts b/example/scripts/tests/runtime_resource_lifetime.ts new file mode 100644 index 0000000..69202c8 --- /dev/null +++ b/example/scripts/tests/runtime_resource_lifetime.ts @@ -0,0 +1,7 @@ +import { Resource } from "godot"; +import RuntimeConstructedResource from "./runtime_constructed_resource.js"; + +export default class RuntimeResourceLifetime extends Resource { + @Export() + child: RuntimeConstructedResource = new RuntimeConstructedResource("field child"); +} diff --git a/example/scripts/tests/tests_runner.gd b/example/scripts/tests/tests_runner.gd index 3c68c58..7203519 100644 --- a/example/scripts/tests/tests_runner.gd +++ b/example/scripts/tests/tests_runner.gd @@ -3,6 +3,7 @@ extends Node const TEST_FINISHED_SIGNAL := "test_finished" const RUN_TEST_METHOD := "run_test" const PASS_MARKER := "[GodeTest] all tests passed" +const RUNTIME_RESOURCE_SCRIPT_PATH := "res://scripts/tests/runtime_resource_lifetime.ts" const RUNTIME_NESTED_RESOURCE_PATH := "res://resources/tests/runtime_nested_outer.tres" const RUNTIME_NESTED_INNER_PATH := "res://resources/tests/runtime_nested_inner.tres" @@ -26,6 +27,8 @@ func _ready() -> void: func _run_next() -> void: current_index += 1 if current_index >= tests.size(): + if not _verify_resource_script_lifetime(): + return if not _verify_nested_resource_fixture(): return print(PASS_MARKER) @@ -151,3 +154,27 @@ func _verify_nested_resource_fixture() -> bool: nested_resource.set_script(null) nested_container.set_script(null) return true + + +func _verify_resource_script_lifetime() -> bool: + var resource_script = ResourceLoader.load(RUNTIME_RESOURCE_SCRIPT_PATH) + var weak_script = weakref(resource_script) + var resource := Resource.new() + for iteration in range(2): + resource.set_script(resource_script) + if resource.get_script() != resource_script: + _fail("Engine-created Resource did not keep its script") + return false + var child_resource = resource.get("child") + if not child_resource is Resource or child_resource.get_script() == null: + _fail("Resource created in a field initializer did not receive its script") + return false + if child_resource.get("label") != "field child": + _fail("Resource field initializer did not preserve its exported state") + return false + resource.set_script(null) + resource_script = null + if weak_script.get_ref() != null: + _fail("Detached Resource retained an orphan TypeScript script instance") + return false + return true diff --git a/src/runtime/value_convert.cpp b/src/runtime/value_convert.cpp index 4dda7d3..9fb3526 100644 --- a/src/runtime/value_convert.cpp +++ b/src/runtime/value_convert.cpp @@ -111,6 +111,9 @@ static std::unordered_map class_registry; static std::vector class_order; static std::unordered_map object_cache; static thread_local godot::Object *script_instance_owner = nullptr; +// Consuming the owner at super() must not end its construction guard: Godot +// installs the ScriptInstance only after the full JS constructor returns. +static thread_local std::vector script_instance_construction_owners; static thread_local godot::Object *adopted_owner = nullptr; static thread_local Napi::Object *adopted_object = nullptr; @@ -133,9 +136,11 @@ constexpr uint64_t JS_MAX_SAFE_INTEGER_UINT64 = 9007199254740991ULL; ScriptInstanceOwnerScope::ScriptInstanceOwnerScope(godot::Object *p_owner) : previous_owner(script_instance_owner) { script_instance_owner = p_owner; + script_instance_construction_owners.push_back(p_owner); } ScriptInstanceOwnerScope::~ScriptInstanceOwnerScope() { + script_instance_construction_owners.pop_back(); script_instance_owner = previous_owner; } @@ -518,6 +523,15 @@ void register_godot_instance(godot::Object *obj, Napi::Object js_obj) { if (!resource || resource->get_script().get_type() != godot::Variant::NIL) { return; } + // Object::set_script() has not installed the engine-created instance yet. + // Reattaching here would create an inner instance which the outer call + // overwrites without freeing. Nested new Resources have a different owner + // and must still receive their own script. + for (godot::Object *constructing_owner : script_instance_construction_owners) { + if (obj == constructing_owner) { + return; + } + } Napi::Value ctor_value = js_obj.Get("constructor"); if (!ctor_value.IsFunction()) { return; From f3885ed6573de32d3aa3556f5efcc08003fdd3cf Mon Sep 17 00:00:00 2001 From: moluopro Date: Thu, 22 Oct 2026 18:39:46 +0800 Subject: [PATCH 3/9] fix(compiler): resolve locally exported default classes --- .../gode/runtime/typescript_compiler.js | 36 ++++++++++++++-- .../tests/runtime_assignment_resource.ts | 8 ++++ .../tests/runtime_export_clause_resource.ts | 8 ++++ .../scripts/tests/runtime_integration_test.ts | 19 +++++++++ test/test_typescript_compiler_js.py | 41 +++++++++++++------ 5 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 example/scripts/tests/runtime_assignment_resource.ts create mode 100644 example/scripts/tests/runtime_export_clause_resource.ts diff --git a/example/addons/gode/runtime/typescript_compiler.js b/example/addons/gode/runtime/typescript_compiler.js index 21f4219..f97415a 100644 --- a/example/addons/gode/runtime/typescript_compiler.js +++ b/example/addons/gode/runtime/typescript_compiler.js @@ -820,11 +820,35 @@ return replacement === specifierText ? "" : replacement; } + function findDefaultClass(tsApi, sourceFile) { + let exportedName; + for (const statement of sourceFile.statements) { + if (tsApi.isClassDeclaration(statement) && statement.modifiers && + statement.modifiers.some(modifier => modifier.kind === tsApi.SyntaxKind.DefaultKeyword)) { + return statement; + } + if (tsApi.isExportAssignment(statement) && !statement.isExportEquals && + tsApi.isIdentifier(statement.expression)) { + exportedName = statement.expression.text; + } else if (tsApi.isExportDeclaration(statement) && !statement.moduleSpecifier && + !statement.isTypeOnly && statement.exportClause && tsApi.isNamedExports(statement.exportClause)) { + for (const specifier of statement.exportClause.elements) { + if (!specifier.isTypeOnly && specifier.name.text === "default") { + exportedName = (specifier.propertyName || specifier.name).text; + } + } + } + } + return sourceFile.statements.find(statement => tsApi.isClassDeclaration(statement) && + statement.name && statement.name.text === exportedName); + } + function createProjectModuleSpecifierTransformer(tsApi, sourcePath, context, config) { const baseHost = createBaseHost(context, tsApi); return (transformContext) => { const factory = transformContext.factory || tsApi.factory; + let defaultClass; function rewriteModuleSpecifier(specifierNode) { if (!isStringLiteralNode(tsApi, specifierNode)) { @@ -835,11 +859,12 @@ } function visit(node) { - // Record the source on the default class, including anonymous classes. + // Record the source on the actual default class, including local exports + // and anonymous classes. // The native Resource constructor uses this to attach the script to // the existing JS object instead of invoking its constructor again. - if (tsApi.isClassDeclaration(node) && node.heritageClauses && - node.modifiers && node.modifiers.some(m => m.kind === tsApi.SyntaxKind.DefaultKeyword)) { + if (node === defaultClass && node.heritageClauses && + node.heritageClauses.some(clause => clause.token === tsApi.SyntaxKind.ExtendsKeyword)) { const marker = factory.createPropertyDeclaration( [factory.createModifier(tsApi.SyntaxKind.StaticKeyword)], factory.createComputedPropertyName(factory.createCallExpression( @@ -886,7 +911,10 @@ return tsApi.visitEachChild(node, visit, transformContext); } - return (sourceFile) => tsApi.visitNode(sourceFile, visit); + return (sourceFile) => { + defaultClass = findDefaultClass(tsApi, sourceFile); + return tsApi.visitNode(sourceFile, visit); + }; }; } diff --git a/example/scripts/tests/runtime_assignment_resource.ts b/example/scripts/tests/runtime_assignment_resource.ts new file mode 100644 index 0000000..1b37e47 --- /dev/null +++ b/example/scripts/tests/runtime_assignment_resource.ts @@ -0,0 +1,8 @@ +import { Resource } from "godot"; + +class RuntimeAssignmentResource extends Resource { + @Export() + label: string = "assignment export"; +} + +export default RuntimeAssignmentResource; diff --git a/example/scripts/tests/runtime_export_clause_resource.ts b/example/scripts/tests/runtime_export_clause_resource.ts new file mode 100644 index 0000000..0d57436 --- /dev/null +++ b/example/scripts/tests/runtime_export_clause_resource.ts @@ -0,0 +1,8 @@ +import { Resource } from "godot"; + +class RuntimeExportClauseResource extends Resource { + @Export() + label: string = "export clause"; +} + +export { RuntimeExportClauseResource as default }; diff --git a/example/scripts/tests/runtime_integration_test.ts b/example/scripts/tests/runtime_integration_test.ts index 892e8d9..33b2005 100644 --- a/example/scripts/tests/runtime_integration_test.ts +++ b/example/scripts/tests/runtime_integration_test.ts @@ -1,4 +1,6 @@ import RuntimeConstructedResource from "./runtime_constructed_resource.js"; +import RuntimeAssignmentResource from "./runtime_assignment_resource.js"; +import RuntimeExportClauseResource from "./runtime_export_clause_resource.js"; import nodeAssert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; @@ -567,6 +569,23 @@ class RuntimeIntegrationTest extends RuntimeSameFileExportBase { nodeAssert.equal(restoredChildren.length, 1); nodeAssert.equal(restoredChildren[0].get("label"), "nested child"); + const constructedExports = [ + { resource: new RuntimeAssignmentResource(), source: "runtime_assignment_resource.ts", label: "assignment export" }, + { resource: new RuntimeExportClauseResource(), source: "runtime_export_clause_resource.ts", label: "export clause" }, + ]; + for (const [index, fixture] of constructedExports.entries()) { + const script = fixture.resource.get_script() as Resource; + assert(script !== null, `constructed ${fixture.source} has no script`); + nodeAssert.equal(script.resource_path, `res://scripts/tests/${fixture.source}`); + nodeAssert.equal(fixture.resource.get("label"), fixture.label); + fixture.resource.label = `saved export ${index}`; + const fixturePath = `user://gode_constructed_export_${index}.tres`; + nodeAssert.equal(ResourceSaver.save(fixture.resource, fixturePath), GODOT_OK); + const restored = ResourceLoader.load(fixturePath, "", ResourceLoader.CACHE_MODE_IGNORE) as Resource; + nodeAssert.equal(restored.get("label"), `saved export ${index}`); + nodeAssert.equal((restored.get_script() as Resource).resource_path, script.resource_path); + } + const nativeResourceOwner = new GDArray(); const adoptedId = (() => { const child = new RuntimeConstructedResource("retained by Godot"); diff --git a/test/test_typescript_compiler_js.py b/test/test_typescript_compiler_js.py index d1749b0..7a2616c 100644 --- a/test/test_typescript_compiler_js.py +++ b/test/test_typescript_compiler_js.py @@ -98,24 +98,41 @@ def run_compiler_fixture(self, test_body: str): def test_default_class_records_source_for_native_resource_construction(self): self.run_compiler_fixture( r""" + const forms = new Map([ + ["named", "export default class Named extends Base { value = 7; }"], + ["anonymous", "export default class extends Base { value = 7; }"], + ["assignment", "class Named extends Base { value = 7; } export default Named;"], + ["exported_assignment", "export class Named extends Base { value = 7; } export default Named;"], + ["specifier", "class Named extends Base { value = 7; } export { Named as default };"], + ["forward_specifier", "export { Named as default }; export class Named extends Base { value = 7; }"] + ]); const files = new Map([ ["res://tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2022", types: [] } })], - ["res://scripts/named.ts", "class Base {} export default class Named extends Base { value = 7; }"], - ["res://scripts/anonymous.ts", "class Base {} export default class extends Base { value = 9; }"] + ...Array.from(forms, ([name, declaration]) => [`res://scripts/${name}.ts`, ` + class Base {} + ${declaration} + export class Helper extends Base {} + export function shadowClass() { class Named extends Base {} return Named; } + `]) ]); const result = runCompiler(files); assertOk(result); - for (const output of result.outputs) { - const code = output.code.replace("export default class", "return class"); - const cls = new Function(code)(); - const marker = Symbol.for("__gode.script_path__"); - if (!Object.hasOwn(cls, marker) || cls[marker] !== output.source) { - throw new Error("Default class has no own source metadata: " + output.source); - } - if (new cls().value !== (output.source.endsWith("named.ts") ? 7 : 9)) { - throw new Error("Source metadata changed instance initialization"); + (async () => { + for (const output of result.outputs) { + const module = await import("data:text/javascript;base64," + Buffer.from(output.code).toString("base64")); + const cls = module.default; + const marker = Symbol.for("__gode.script_path__"); + if (!Object.hasOwn(cls, marker) || cls[marker] !== output.source) { + throw new Error("Default class has no own source metadata: " + output.source); + } + if (new cls().value !== 7) { + throw new Error("Source metadata changed instance initialization"); + } + if (Object.hasOwn(module.Helper, marker) || Object.hasOwn(module.shadowClass(), marker)) { + throw new Error("Source metadata was added to a different class: " + output.source); + } } - } + })().catch(error => { console.error(error); process.exitCode = 1; }); """ ) From 1ba9389ca869af788052b220521dc2e4918d9295 Mon Sep 17 00:00:00 2001 From: moluopro Date: Thu, 22 Oct 2026 18:39:58 +0800 Subject: [PATCH 4/9] fix(compiler): install script metadata before static initialization --- .../gode/runtime/typescript_compiler.js | 4 +- .../scripts/tests/runtime_integration_test.ts | 7 ++ .../scripts/tests/runtime_static_resource.ts | 13 +++ test/test_typescript_compiler_js.py | 87 +++++++++++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 example/scripts/tests/runtime_static_resource.ts diff --git a/example/addons/gode/runtime/typescript_compiler.js b/example/addons/gode/runtime/typescript_compiler.js index f97415a..8df7c4a 100644 --- a/example/addons/gode/runtime/typescript_compiler.js +++ b/example/addons/gode/runtime/typescript_compiler.js @@ -860,7 +860,7 @@ function visit(node) { // Record the source on the actual default class, including local exports - // and anonymous classes. + // and anonymous classes, before any user static initializer can construct it. // The native Resource constructor uses this to attach the script to // the existing JS object instead of invoking its constructor again. if (node === defaultClass && node.heritageClauses && @@ -872,7 +872,7 @@ undefined, [factory.createStringLiteral("__gode.script_path__")])), undefined, undefined, factory.createStringLiteral(sourcePath)); node = factory.updateClassDeclaration(node, node.modifiers, node.name, - node.typeParameters, node.heritageClauses, [...node.members, marker]); + node.typeParameters, node.heritageClauses, [marker, ...node.members]); } if (tsApi.isImportDeclaration(node) && node.moduleSpecifier) { const moduleSpecifier = rewriteModuleSpecifier(node.moduleSpecifier); diff --git a/example/scripts/tests/runtime_integration_test.ts b/example/scripts/tests/runtime_integration_test.ts index 33b2005..fc72557 100644 --- a/example/scripts/tests/runtime_integration_test.ts +++ b/example/scripts/tests/runtime_integration_test.ts @@ -1,6 +1,7 @@ import RuntimeConstructedResource from "./runtime_constructed_resource.js"; import RuntimeAssignmentResource from "./runtime_assignment_resource.js"; import RuntimeExportClauseResource from "./runtime_export_clause_resource.js"; +import RuntimeStaticResource from "./runtime_static_resource.js"; import nodeAssert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; @@ -572,6 +573,8 @@ class RuntimeIntegrationTest extends RuntimeSameFileExportBase { const constructedExports = [ { resource: new RuntimeAssignmentResource(), source: "runtime_assignment_resource.ts", label: "assignment export" }, { resource: new RuntimeExportClauseResource(), source: "runtime_export_clause_resource.ts", label: "export clause" }, + { resource: RuntimeStaticResource.singleton, source: "runtime_static_resource.ts", label: "assignment export" }, + { resource: RuntimeStaticResource.from_block, source: "runtime_static_resource.ts", label: "assignment export" }, ]; for (const [index, fixture] of constructedExports.entries()) { const script = fixture.resource.get_script() as Resource; @@ -584,6 +587,10 @@ class RuntimeIntegrationTest extends RuntimeSameFileExportBase { const restored = ResourceLoader.load(fixturePath, "", ResourceLoader.CACHE_MODE_IGNORE) as Resource; nodeAssert.equal(restored.get("label"), `saved export ${index}`); nodeAssert.equal((restored.get_script() as Resource).resource_path, script.resource_path); + if (fixture.resource instanceof RuntimeStaticResource) { + nodeAssert.equal(fixture.resource.get("static_label"), "static initialization"); + nodeAssert.equal(restored.get("static_label"), "static initialization"); + } } const nativeResourceOwner = new GDArray(); diff --git a/example/scripts/tests/runtime_static_resource.ts b/example/scripts/tests/runtime_static_resource.ts new file mode 100644 index 0000000..137525a --- /dev/null +++ b/example/scripts/tests/runtime_static_resource.ts @@ -0,0 +1,13 @@ +import RuntimeAssignmentResource from "./runtime_assignment_resource.js"; + +export default class RuntimeStaticResource extends RuntimeAssignmentResource { + static readonly singleton = new RuntimeStaticResource(); + static from_block: RuntimeStaticResource; + + static { + this.from_block = new this(); + } + + @Export() + static_label: string = "static initialization"; +} diff --git a/test/test_typescript_compiler_js.py b/test/test_typescript_compiler_js.py index 7a2616c..edbf05a 100644 --- a/test/test_typescript_compiler_js.py +++ b/test/test_typescript_compiler_js.py @@ -136,6 +136,93 @@ class Base {} """ ) + def test_default_class_source_is_available_during_static_initialization(self): + self.run_compiler_fixture( + r""" + const forms = new Map([ + ["named", "export default class Named extends Base { static singleton = new Named();"], + ["anonymous", "export default class extends Base { static singleton: Base = new this();"], + ["assignment", "class Named extends Base { static singleton = new Named();"], + ["specifier", "class Named extends Base { static singleton = new Named();"] + ]); + const files = new Map([ + ["res://tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2022", types: [] } })], + ...Array.from(forms, ([name, declaration]) => [`res://scripts/${name}.ts`, ` + class Base { + constructionPath: string | undefined; + constructor() { + const ctor = new.target as any; + const marker = Symbol.for("__gode.script_path__"); + this.constructionPath = Object.prototype.hasOwnProperty.call(ctor, marker) ? ctor[marker] : undefined; + } + } + ${declaration} + static blockInstance: Base; + static { this.blockInstance = new this(); } + value = 7; + } + ${name === "assignment" ? "export default Named;" : name === "specifier" ? "export { Named as default };" : ""} + `]) + ]); + const result = runCompiler(files); + assertOk(result); + (async () => { + for (const output of result.outputs) { + const { default: cls } = await import("data:text/javascript;base64," + Buffer.from(output.code).toString("base64")); + for (const instance of [cls.singleton, cls.blockInstance, new cls()]) { + if (instance.constructionPath !== output.source || instance.value !== 7) { + throw new Error("Construction ran before source metadata or changed field initialization: " + output.source); + } + } + } + })().catch(error => { console.error(error); process.exitCode = 1; }); + """ + ) + + def test_default_subclass_installs_own_source_before_static_construction(self): + self.run_compiler_fixture( + r""" + const files = new Map([ + ["res://tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2022", types: [] } })], + ["res://scripts/base.ts", ` + class NativeResource { + constructionPath: string; + constructor() { this.constructionPath = (new.target as any)[Symbol.for("__gode.script_path__")]; } + } + export default class Parent extends NativeResource {} + `], + ["res://scripts/child.ts", ` + import Parent from './base'; + class Child extends Parent { + static singleton = new Child(); + static blockInstance: Child; + static { this.blockInstance = new this(); } + } + export { Child as default }; + `] + ]); + const result = runCompiler(files); + assertOk(result); + const baseOutput = result.outputs.find(output => output.source.endsWith("base.ts")); + const childOutput = result.outputs.find(output => output.source.endsWith("child.ts")); + const baseUrl = "data:text/javascript;base64," + Buffer.from(baseOutput.code).toString("base64"); + const childCode = childOutput.code.replace('"./base.js"', JSON.stringify(baseUrl)).replace("'./base.js'", JSON.stringify(baseUrl)); + (async () => { + const { default: Parent } = await import(baseUrl); + const { default: Child } = await import("data:text/javascript;base64," + Buffer.from(childCode).toString("base64")); + const marker = Symbol.for("__gode.script_path__"); + if (!Object.hasOwn(Child, marker) || Child[marker] !== childOutput.source || Parent[marker] !== baseOutput.source) { + throw new Error("Subclass source metadata was inherited or overwrote its parent"); + } + for (const instance of [Child.singleton, Child.blockInstance, new Child()]) { + if (instance.constructionPath !== childOutput.source) { + throw new Error("Subclass construction used the parent script path"); + } + } + })().catch(error => { console.error(error); process.exitCode = 1; }); + """ + ) + def test_tsconfig_include_exclude_controls_project_roots_and_imported_outputs(self): self.run_compiler_fixture( r""" From 9e500aae529c98ed2fa06ab429ce43129c524fa5 Mon Sep 17 00:00:00 2001 From: moluopro Date: Thu, 22 Oct 2026 18:40:14 +0800 Subject: [PATCH 5/9] fix(compiler): preserve signals declared by Godot script classes --- .../gode/runtime/typescript_compiler.js | 61 +++++- .../tests/runtime_constructed_resource.ts | 5 +- .../scripts/tests/runtime_integration_test.ts | 9 + test/test_typescript_compiler_js.py | 179 ++++++++++++++++++ 4 files changed, 250 insertions(+), 4 deletions(-) diff --git a/example/addons/gode/runtime/typescript_compiler.js b/example/addons/gode/runtime/typescript_compiler.js index 8df7c4a..681826a 100644 --- a/example/addons/gode/runtime/typescript_compiler.js +++ b/example/addons/gode/runtime/typescript_compiler.js @@ -843,7 +843,38 @@ statement.name && statement.name.text === exportedName); } - function createProjectModuleSpecifierTransformer(tsApi, sourcePath, context, config) { + function isGodotScriptClass(tsApi, program, sourcePath) { + const sourceFile = program.getSourceFile(sourcePath); + const classNode = sourceFile && findDefaultClass(tsApi, sourceFile); + if (!classNode) { + return false; + } + const checker = program.getTypeChecker(); + const visited = new Set(); + function inheritsGodotObject(type) { + const symbol = type.getSymbol(); + if (!symbol || visited.has(symbol)) { + return false; + } + visited.add(symbol); + return (symbol.declarations || []).some(declaration => { + if (!tsApi.isClassDeclaration(declaration) && !tsApi.isClassExpression(declaration)) { + return false; + } + if (declaration.name && (declaration.name.text === "GodotObject" || declaration.name.text === "_GodotObject") && + normalizePath(declaration.getSourceFile().fileName) === "res://addons/gode/types/godot.d.ts") { + return true; + } + // Follow runtime extends clauses, not implemented or merged interfaces. + return (declaration.heritageClauses || []).some(clause => + clause.token === tsApi.SyntaxKind.ExtendsKeyword && clause.types.some(base => + inheritsGodotObject(checker.getTypeAtLocation(base.expression)))); + }); + } + return inheritsGodotObject(checker.getTypeAtLocation(classNode)); + } + + function createProjectModuleSpecifierTransformer(tsApi, sourcePath, context, config, preserveSignals) { const baseHost = createBaseHost(context, tsApi); return (transformContext) => { @@ -858,6 +889,26 @@ return replacement ? factory.createStringLiteral(replacement) : specifierNode; } + function preserveNativeSignal(member) { + if (!tsApi.isPropertyDeclaration(member) || member.initializer || !tsApi.isIdentifier(member.name) || + (member.modifiers && member.modifiers.some(modifier => modifier.kind === tsApi.SyntaxKind.StaticKeyword)) || + !member.type || !tsApi.isTypeReferenceNode(member.type) || !tsApi.isIdentifier(member.type.typeName) || + member.type.typeName.text !== "Signal" || !member.type.typeArguments || !member.type.typeArguments.length) { + return member; + } + // Signal declares an engine-owned signal. Its object is bound during + // super(), so an emitted class field would replace it with undefined. + const modifiers = member.modifiers || []; + if (modifiers.some(modifier => modifier.kind === tsApi.SyntaxKind.DeclareKeyword)) { + return member; + } + const declaration = factory.updatePropertyDeclaration(member, + [...modifiers, factory.createModifier(tsApi.SyntaxKind.DeclareKeyword)], member.name, + member.questionToken || member.exclamationToken, member.type, member.initializer); + declaration.flags |= tsApi.NodeFlags.Ambient; + return declaration; + } + function visit(node) { // Record the source on the actual default class, including local exports // and anonymous classes, before any user static initializer can construct it. @@ -871,8 +922,11 @@ factory.createPropertyAccessExpression(factory.createIdentifier("Symbol"), "for"), undefined, [factory.createStringLiteral("__gode.script_path__")])), undefined, undefined, factory.createStringLiteral(sourcePath)); + // Signal annotations are native declarations only on actual Godot + // subclasses. Ordinary TypeScript classes keep their field semantics. + const members = preserveSignals ? node.members.map(preserveNativeSignal) : node.members; node = factory.updateClassDeclaration(node, node.modifiers, node.name, - node.typeParameters, node.heritageClauses, [marker, ...node.members]); + node.typeParameters, node.heritageClauses, [marker, ...members]); } if (tsApi.isImportDeclaration(node) && node.moduleSpecifier) { const moduleSpecifier = rewriteModuleSpecifier(node.moduleSpecifier); @@ -988,7 +1042,8 @@ fileName: sourcePath, reportDiagnostics: true, transformers: { - before: [createProjectModuleSpecifierTransformer(tsApi, sourcePath, context, config)] + before: [createProjectModuleSpecifierTransformer(tsApi, sourcePath, context, config, + isGodotScriptClass(tsApi, program, sourcePath))] } }); outputs.push({ diff --git a/example/scripts/tests/runtime_constructed_resource.ts b/example/scripts/tests/runtime_constructed_resource.ts index db8df50..f24e42f 100644 --- a/example/scripts/tests/runtime_constructed_resource.ts +++ b/example/scripts/tests/runtime_constructed_resource.ts @@ -1,7 +1,9 @@ -import { Resource, Vector2 } from "godot"; +import { Resource, Signal, Vector2 } from "godot"; export default class RuntimeConstructedResource extends Resource { static constructions: number = 0; + typed_completed!: Signal<(label: string) => void>; + signal_ready_in_constructor: boolean = false; @Export() label: string = "default"; @@ -16,5 +18,6 @@ export default class RuntimeConstructedResource extends Resource { super(); RuntimeConstructedResource.constructions++; this.label = label; + this.signal_ready_in_constructor = typeof this.typed_completed?.emit === "function"; } } diff --git a/example/scripts/tests/runtime_integration_test.ts b/example/scripts/tests/runtime_integration_test.ts index fc72557..8758e72 100644 --- a/example/scripts/tests/runtime_integration_test.ts +++ b/example/scripts/tests/runtime_integration_test.ts @@ -552,6 +552,11 @@ class RuntimeIntegrationTest extends RuntimeSameFileExportBase { const directResource = new RuntimeConstructedResource("direct"); nodeAssert.equal(RuntimeConstructedResource.constructions, beforeConstructions + 1); assert(directResource.get_script() !== null, "new Resource subclass has no script"); + nodeAssert.equal(directResource.signal_ready_in_constructor, true, "typed signal was overwritten before the constructor body"); + let directSignalLabel = ""; + directResource.typed_completed.connect(label => { directSignalLabel = label; }); + directResource.typed_completed.emit("direct signal"); + nodeAssert.equal(directSignalLabel, "direct signal"); nodeAssert.equal(directResource.get("label"), "direct"); directResource.set("label", "from Godot"); nodeAssert.equal(directResource.label, "from Godot"); @@ -569,6 +574,10 @@ class RuntimeIntegrationTest extends RuntimeSameFileExportBase { const restoredChildren = restoredDirect.get("children") as RuntimeConstructedResource[]; nodeAssert.equal(restoredChildren.length, 1); nodeAssert.equal(restoredChildren[0].get("label"), "nested child"); + let restoredSignalLabel = ""; + restoredDirect.typed_completed.connect(label => { restoredSignalLabel = label; }); + restoredDirect.typed_completed.emit("restored signal"); + nodeAssert.equal(restoredSignalLabel, "restored signal"); const constructedExports = [ { resource: new RuntimeAssignmentResource(), source: "runtime_assignment_resource.ts", label: "assignment export" }, diff --git a/test/test_typescript_compiler_js.py b/test/test_typescript_compiler_js.py index edbf05a..984552e 100644 --- a/test/test_typescript_compiler_js.py +++ b/test/test_typescript_compiler_js.py @@ -77,6 +77,16 @@ def run_compiler_fixture(self, test_body: str): } } + const godotSignalTypes = `declare module 'godot' { + export class GodotObject {} + export class Resource extends GodotObject {} + export interface Signal { emit: T; } + }`; + const godotResourceUrl = "data:text/javascript;base64," + Buffer.from("export class Resource {}").toString("base64"); + function withGodotResourceMock(code) { + return code.replace(/from (["'])godot\1/g, "from " + JSON.stringify(godotResourceUrl)); + } + __TEST_BODY__ """ ).replace("__TEST_BODY__", textwrap.dedent(test_body)) @@ -223,6 +233,175 @@ class Child extends Parent { """ ) + def test_native_signal_declarations_preserve_binding_without_changing_other_fields(self): + self.run_compiler_fixture( + r""" + const results = []; + for (const target of ["ES2022", "ES2015"]) { + for (const useDefineForClassFields of [true, false]) { + const path = `res://scripts/signal_${target}_${useDefineForClassFields}.ts`; + const files = new Map([ + ["res://tsconfig.json", JSON.stringify({ compilerOptions: { target, useDefineForClassFields, types: [] } })], + ["res://addons/gode/types/godot.d.ts", godotSignalTypes], + [path, ` + import { Resource, type Signal } from 'godot'; + export const bound: Signal<() => void> = { emit() {} }; + export const explicit: Signal<() => void> = { emit() {} }; + export const decorated: string[] = []; + function observe(_target: any, name: string) { decorated.push(name); } + class Base extends Resource { + constructor() { + super(); + Object.assign(this, { completed: bound, optional: bound, declared: bound, ordinary: 42, initialized: bound }); + } + } + class Script extends Base { + @observe completed!: Signal<() => void>; + optional?: Signal<() => void>; + declare declared: Signal<() => void>; + initialized: Signal<() => void> = explicit; + ordinary!: number; + value = 7; + static completed: Signal<() => void>; + static initialized: Signal<() => void> = explicit; + seenInConstructor: Signal<() => void>; + constructor() { super(); this.seenInConstructor = this.completed; } + } + export { Script as default }; + export class Helper extends Base { completed!: Signal<() => void>; } + `] + ]); + const result = runCompiler(files); + assertOk(result); + results.push({ output: result.outputs[0], target, useDefineForClassFields }); + } + } + (async () => { + for (const { output, target, useDefineForClassFields } of results) { + const module = await import("data:text/javascript;base64," + Buffer.from(withGodotResourceMock(output.code)).toString("base64")); + const cls = module.default; + const instance = new cls(); + if ([instance.completed, instance.optional, instance.declared, instance.seenInConstructor].some(signal => signal !== module.bound)) { + throw new Error("A Signal declaration replaced the native binding: " + output.source); + } + if (instance.initialized !== module.explicit || cls.initialized !== module.explicit || instance.value !== 7) { + throw new Error("Signal handling changed explicit field initializers"); + } + if (instance.ordinary !== (useDefineForClassFields ? undefined : 42) || + new module.Helper().completed !== (useDefineForClassFields ? undefined : module.bound) || + Object.hasOwn(cls, "completed") !== (target === "ES2022" && useDefineForClassFields)) { + throw new Error("Signal handling changed ordinary, helper, or static field semantics"); + } + if (JSON.stringify(module.decorated) !== JSON.stringify(["completed"])) { + throw new Error("Signal declaration lost its decorator"); + } + } + })().catch(error => { console.error(error); process.exitCode = 1; }); + """ + ) + + def test_ordinary_signal_fields_keep_typescript_initialization_semantics(self): + self.run_compiler_fixture( + r""" + const forms = new Map([ + ["local_signal", ` + interface Signal { emit: T; } + class Base { constructor() { Object.assign(this, { completed: bound }); } } + `], + ["native_type_only", ` + import type { Signal } from 'godot'; + class Base { constructor() { Object.assign(this, { completed: bound }); } } + `], + ["native_implements_only", ` + import type { GodotObject, Signal } from 'godot'; + class Base implements GodotObject { constructor() { Object.assign(this, { completed: bound }); } } + `], + ["native_merged_interface", ` + import type { Resource, Signal } from 'godot'; + interface Pretend extends Resource {} + class Pretend { constructor() { Object.assign(this, { completed: bound }); } } + class Base extends Pretend {} + `], + ["native_merged_interface_with_base", ` + import type { Resource, Signal } from 'godot'; + interface Pretend extends Resource {} + class OrdinaryBase { constructor() { Object.assign(this, { completed: bound }); } } + class Pretend extends OrdinaryBase {} + class Base extends Pretend {} + `], + ["native_class_names", ` + interface Signal { emit: T; } + class GodotObject { constructor() { Object.assign(this, { completed: bound }); } } + class Resource extends GodotObject {} + class Base extends Resource {} + `] + ]); + const results = []; + for (const target of ["ES2022", "ES2015"]) { + for (const useDefineForClassFields of [true, false]) { + const files = new Map([ + ["res://tsconfig.json", JSON.stringify({ compilerOptions: { target, useDefineForClassFields, types: [] } })], + ["res://addons/gode/types/godot.d.ts", godotSignalTypes], + ...Array.from(forms, ([name, base]) => [`res://scripts/${name}_${target}_${useDefineForClassFields}.ts`, ` + ${base} + export const bound: Signal<() => void> = { emit() {} }; + export default class Ordinary extends Base { completed!: Signal<() => void>; } + `]) + ]); + const result = runCompiler(files); + assertOk(result); + for (const output of result.outputs) results.push({ output, useDefineForClassFields }); + } + } + (async () => { + for (const { output, useDefineForClassFields } of results) { + const module = await import("data:text/javascript;base64," + Buffer.from(output.code).toString("base64")); + const expected = useDefineForClassFields ? undefined : module.bound; + if (new module.default().completed !== expected) { + throw new Error("Godot signal handling changed an ordinary class field: " + output.source); + } + } + })().catch(error => { console.error(error); process.exitCode = 1; }); + """ + ) + + def test_native_signal_declarations_follow_resolved_godot_inheritance(self): + self.run_compiler_fixture( + r""" + const forms = [ + ["alias", "import { Resource as NativeResource, type Signal } from 'godot';", "NativeResource"], + ["namespace", "import * as godot from 'godot'; import type { Signal } from 'godot';", "godot.Resource"], + ["script_base", "import NativeResource from './base'; import type { Signal } from 'godot';", "NativeResource"], + ["generic_script_base", "import { GenericBase } from './base'; import type { Signal } from 'godot';", "GenericBase"], + ["anonymous", "import { Resource, type Signal } from 'godot';", "Resource"] + ]; + const files = new Map([ + ["res://tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2022", useDefineForClassFields: true, types: [] } })], + ["res://addons/gode/types/godot.d.ts", godotSignalTypes], + ["res://scripts/base.ts", "import { Resource } from 'godot'; export default class ScriptBase extends Resource {} export class GenericBase extends Resource { value?: T; }"], + ...Array.from(forms, ([name, imports, base]) => [`res://scripts/${name}.ts`, ` + ${imports} + export const bound: Signal<() => void> = { emit() {} }; + class Base extends ${base} { constructor() { super(); Object.assign(this, { completed: bound }); } } + export default class ${name === "anonymous" ? "" : "Script"} extends Base { completed!: Signal<() => void>; } + `]) + ]); + const result = runCompiler(files); + assertOk(result); + const baseOutput = result.outputs.find(output => output.source.endsWith("/base.ts")); + const baseUrl = "data:text/javascript;base64," + Buffer.from(withGodotResourceMock(baseOutput.code)).toString("base64"); + (async () => { + for (const output of result.outputs.filter(output => !output.source.endsWith("/base.ts"))) { + const code = withGodotResourceMock(output.code).replace(/from (["'])\.\/base\.js\1/g, "from " + JSON.stringify(baseUrl)); + const module = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64")); + if (new module.default().completed !== module.bound) { + throw new Error("Resolved Godot ancestry lost its signal binding: " + output.source); + } + } + })().catch(error => { console.error(error); process.exitCode = 1; }); + """ + ) + def test_tsconfig_include_exclude_controls_project_roots_and_imported_outputs(self): self.run_compiler_fixture( r""" From bf7f612ad17cb26c7aaffeef5d5aa28b78144226 Mon Sep 17 00:00:00 2001 From: moluopro Date: Thu, 22 Oct 2026 18:40:23 +0800 Subject: [PATCH 6/9] fix(runtime): resolve live owners while reloading script instances --- .../scripts/tests/runtime_reload_resource.ts | 50 +++++++++++ example/scripts/tests/tests_runner.gd | 89 +++++++++++++++++++ src/script/typescript_script_runtime.cpp | 19 +++- 3 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 example/scripts/tests/runtime_reload_resource.ts diff --git a/example/scripts/tests/runtime_reload_resource.ts b/example/scripts/tests/runtime_reload_resource.ts new file mode 100644 index 0000000..69d3979 --- /dev/null +++ b/example/scripts/tests/runtime_reload_resource.ts @@ -0,0 +1,50 @@ +import { Resource } from "godot"; + +export default class RuntimeReloadResource extends Resource { + static constructions: number = 0; + static spawnChildren: boolean = true; + static constructed: RuntimeReloadResource[] = []; + static pendingDetach: Resource[] = []; + + @Export() + label: number = 0; + + @Export() + child: RuntimeReloadResource | null = null; + + constructor(createChild: boolean = true) { + super(); + RuntimeReloadResource.constructions++; + RuntimeReloadResource.constructed.push(this); + + const pending = RuntimeReloadResource.pendingDetach; + RuntimeReloadResource.pendingDetach = []; + for (const resource of pending) { + if (resource.get_instance_id() !== this.get_instance_id()) { + resource.set_script(null); + } + } + + if (createChild !== false && RuntimeReloadResource.spawnChildren) { + this.child = new RuntimeReloadResource(false); + } + } + + static constructionCount(): number { + return RuntimeReloadResource.constructions; + } + + static takeConstructed(): RuntimeReloadResource[] { + const resources = RuntimeReloadResource.constructed; + RuntimeReloadResource.constructed = []; + return resources; + } + + static disableChildren(): void { + RuntimeReloadResource.spawnChildren = false; + } + + static detachOnNextConstruction(resources: Resource[]): void { + RuntimeReloadResource.pendingDetach = resources; + } +} diff --git a/example/scripts/tests/tests_runner.gd b/example/scripts/tests/tests_runner.gd index 7203519..cc1a715 100644 --- a/example/scripts/tests/tests_runner.gd +++ b/example/scripts/tests/tests_runner.gd @@ -6,6 +6,7 @@ const PASS_MARKER := "[GodeTest] all tests passed" const RUNTIME_RESOURCE_SCRIPT_PATH := "res://scripts/tests/runtime_resource_lifetime.ts" const RUNTIME_NESTED_RESOURCE_PATH := "res://resources/tests/runtime_nested_outer.tres" const RUNTIME_NESTED_INNER_PATH := "res://resources/tests/runtime_nested_inner.tres" +const RUNTIME_RELOAD_SCRIPT_PATH := "res://scripts/tests/runtime_reload_resource.ts" var tests: Array[Node] = [] var current_index := -1 @@ -31,6 +32,8 @@ func _run_next() -> void: return if not _verify_nested_resource_fixture(): return + if not _verify_resource_script_reload(): + return print(PASS_MARKER) get_tree().quit(0) return @@ -178,3 +181,89 @@ func _verify_resource_script_lifetime() -> bool: _fail("Detached Resource retained an orphan TypeScript script instance") return false return true + + +func _verify_resource_script_reload() -> bool: + var resource_script = ResourceLoader.load(RUNTIME_RELOAD_SCRIPT_PATH) + var resources: Array[Resource] = [] + var children: Array[Resource] = [] + for index in range(15): + var resource := Resource.new() + resource.set_script(resource_script) + resource.set("label", index + 1) + resources.append(resource) + var child = resource.get("child") + if not child is Resource or child.get_script() != resource_script: + _fail("Reload fixture did not adopt its same-class child Resource") + return false + children.append(child) + var constructed: Array = resource_script.call("call_static", "takeConstructed") + var initial_count: int = resource_script.call("call_static", "constructionCount") + if initial_count != 30: + _fail("Reload fixture did not construct exactly 15 parents and 15 children") + return false + if resource_script.reload(true) != OK: + _fail("Resource script reload with state preservation failed") + return false + constructed.append_array(resource_script.call("call_static", "takeConstructed")) + if resource_script.call("call_static", "constructionCount") != initial_count * 3: + _fail("Reload included same-class Resources created during its constructors") + return false + for index in range(resources.size()): + if resources[index].get("label") != index + 1 or resources[index].get("child") != children[index]: + _fail("Resource reload did not preserve exported state and child identity") + return false + _detach_reload_resources(constructed) + resources.clear() + children.clear() + + resource_script.call("call_static", "disableChildren") + for index in range(15): + var resource := Resource.new() + resource.set_script(resource_script) + resource.set("label", index + 1) + resources.append(resource) + constructed = resource_script.call("call_static", "takeConstructed") + initial_count = resource_script.call("call_static", "constructionCount") + if resource_script.reload(false) != OK: + _fail("Resource script reload without state preservation failed") + return false + constructed.append_array(resource_script.call("call_static", "takeConstructed")) + if resource_script.call("call_static", "constructionCount") != initial_count + resources.size(): + _fail("Resource reload did not reconstruct each existing instance exactly once") + return false + for resource in resources: + if resource.get("label") != 0: + _fail("Resource reload(false) preserved an exported value instead of its default") + return false + + # The first constructor removes the scripts from all other pending owners. + # Reload must resolve each owner's current instance before accessing it. + resource_script.call("call_static", "detachOnNextConstruction", resources) + initial_count = resource_script.call("call_static", "constructionCount") + if resource_script.reload(true) != OK: + _fail("Resource script reload with removed pending instances failed") + return false + constructed.append_array(resource_script.call("call_static", "takeConstructed")) + if resource_script.call("call_static", "constructionCount") != initial_count + 1: + _fail("Resource reload accessed instances detached by an earlier constructor") + return false + var attached_count := 0 + for resource in resources: + if resource.get_script() == resource_script: + attached_count += 1 + if attached_count != 1: + _fail("Reload fixture did not detach all other pending instances") + return false + _detach_reload_resources(constructed) + return true + + +func _detach_reload_resources(resources: Array) -> void: + for resource in resources: + if resource.get_script() != null: + resource.set("child", null) + for resource in resources: + if resource.get_script() != null: + resource.set_script(null) + resources.clear() diff --git a/src/script/typescript_script_runtime.cpp b/src/script/typescript_script_runtime.cpp index 156ae0b..fb0c49d 100644 --- a/src/script/typescript_script_runtime.cpp +++ b/src/script/typescript_script_runtime.cpp @@ -344,9 +344,24 @@ Error TypeScriptScript::_reload(bool p_keep_state) { return Error::ERR_INVALID_PARAMETER; } - // Reload all instances + // Constructors can add same-script instances or destroy other owners while + // reloading. Keep owner IDs instead of iterating the mutable instance set or + // retaining ScriptInstance pointers across calls into user code. + std::vector owners; + owners.reserve(instances.size()); for (ScriptInstance *instance : instances) { - if (instance && !instance->is_placeholder()) { + if (instance && instance->get_owner()) { + owners.push_back(ObjectID(instance->get_owner()->get_instance_id())); + } + } + for (ObjectID owner_id : owners) { + Object *owner = ObjectDB::get_instance(owner_id); + if (!owner) { + continue; + } + ScriptInstance *instance = static_cast(gdextension_interface::object_get_script_instance( + owner->_owner, TypeScriptLanguage::get_singleton()->_owner)); + if (instance && instances.has(instance) && !instance->is_placeholder()) { instance->reload(p_keep_state); } } From 674f7c5fb3f79646e97c9cb729ded7672150833d Mon Sep 17 00:00:00 2001 From: moluopro Date: Thu, 22 Oct 2026 18:48:53 +0800 Subject: [PATCH 7/9] chore: prepare 2.4.7 version and changelog --- CHANGELOG-ZH.md | 7 +++++++ CHANGELOG.md | 7 +++++++ CMakeLists.txt | 2 +- example/addons/gode/plugin.cfg | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG-ZH.md b/CHANGELOG-ZH.md index 5b04c15..bace34b 100644 --- a/CHANGELOG-ZH.md +++ b/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 2.4.7 + +- 修复通过 `new` 创建 TypeScript Resource 时的脚本绑定和导出属性保存、加载,覆盖分离式默认导出及静态初始化场景。 +- 修复 Resource 引用处理,避免资源泄漏,并保留 Godot 持有资源的 JavaScript 状态。 +- 修复直接构造 Resource 时的 `Signal` 绑定,保留普通 TypeScript 类原有的字段初始化行为。 +- 修复脚本重载期间构造函数创建或解绑其他实例导致的崩溃。 + ## 2.4.6 - 内置 Node.js 升级至 `24.21.0`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index afae90d..bd6f1ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2.4.7 + +- Fixed script binding and exported-property persistence for directly constructed TypeScript Resources, including local default exports and static initialization. +- Fixed Resource reference handling to prevent leaks while preserving JavaScript state for resources retained by Godot. +- Fixed `Signal` bindings on directly constructed Resources without changing ordinary TypeScript field initialization. +- Fixed script reload crashes when constructors create or detach other instances. + ## 2.4.6 - Updated the embedded Node.js to `24.21.0`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 880b928..440fe2a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.22) cmake_policy(SET CMP0091 NEW) set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" CACHE STRING "Select the MSVC runtime library." FORCE) -project(gode VERSION 2.4.6 LANGUAGES C CXX) +project(gode VERSION 2.4.7 LANGUAGES C CXX) if(NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE) diff --git a/example/addons/gode/plugin.cfg b/example/addons/gode/plugin.cfg index 3f41413..ed62560 100644 --- a/example/addons/gode/plugin.cfg +++ b/example/addons/gode/plugin.cfg @@ -4,5 +4,5 @@ name="gode" description="Godot with TypeScript and Node.js" website="https://godothub.com" author="GodotHub" -version="2.4.6" +version="2.4.7" script="gode.gd" From acdc13b3f93f42ac111091d6fe20cb54e0c9bd6d Mon Sep 17 00:00:00 2001 From: moluopro Date: Thu, 22 Oct 2026 18:54:28 +0800 Subject: [PATCH 8/9] docs: simplify 2.4.7 release notes --- CHANGELOG-ZH.md | 6 ++---- CHANGELOG.md | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CHANGELOG-ZH.md b/CHANGELOG-ZH.md index bace34b..9949c0d 100644 --- a/CHANGELOG-ZH.md +++ b/CHANGELOG-ZH.md @@ -1,9 +1,7 @@ ## 2.4.7 -- 修复通过 `new` 创建 TypeScript Resource 时的脚本绑定和导出属性保存、加载,覆盖分离式默认导出及静态初始化场景。 -- 修复 Resource 引用处理,避免资源泄漏,并保留 Godot 持有资源的 JavaScript 状态。 -- 修复直接构造 Resource 时的 `Signal` 绑定,保留普通 TypeScript 类原有的字段初始化行为。 -- 修复脚本重载期间构造函数创建或解绑其他实例导致的崩溃。 +- 修复自定义 TypeScript 资源的保存和信号问题。 +- 修复资源泄漏及脚本重载崩溃。 ## 2.4.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6f1ba..62a963d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,7 @@ ## 2.4.7 -- Fixed script binding and exported-property persistence for directly constructed TypeScript Resources, including local default exports and static initialization. -- Fixed Resource reference handling to prevent leaks while preserving JavaScript state for resources retained by Godot. -- Fixed `Signal` bindings on directly constructed Resources without changing ordinary TypeScript field initialization. -- Fixed script reload crashes when constructors create or detach other instances. +- Fixed custom TypeScript resource saving and signal handling. +- Fixed resource leaks and script reload crashes. ## 2.4.6 From c258f106ec3a286c19f462f18eb543aab86f84b7 Mon Sep 17 00:00:00 2001 From: moluopro Date: Thu, 22 Oct 2026 19:08:34 +0800 Subject: [PATCH 9/9] docs: introduce the lightweight Godot-JS alternative --- README-ZH.md | 5 +++++ README.md | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/README-ZH.md b/README-ZH.md index e70d2c3..fdb2317 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -9,6 +9,11 @@ Godot 引擎的 JavaScript/TypeScript 脚本支持,运行在所有原生平台 | 支持情况 | ✅ | ✅ | ✅ | ✅ | ✅ | | 最低版本 | 10 | 9 | 10.15 | 16 | Ubuntu 22 | +如果需要轻量级方案,请尝试基于 Gode 的 [Godot-JS](https://github.com/godothub/godot-js/releases): +1. 裁剪了大量 NodeJS 内容,保留 V8 引擎。 +2. 与 Gode 完全一致的代码编写方式。 +3. 支持更多平台,比如 Web。 + ## 快速入门 ### 1. 安装插件 diff --git a/README.md b/README.md index 9cbc11e..887e439 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ JavaScript/TypeScript scripting support for the Godot engine, running on all nat | Supported | ✅ | ✅ | ✅ | ✅ | ✅ | | Minimum Version | 10 | 9 | 10.15 | 16 | Ubuntu 22 | +For a lightweight alternative, try [Godot-JS](https://github.com/godothub/godot-js/releases), based on Gode: + +1. Removes much of Node.js while retaining the V8 engine. +2. Lets you write scripts the same way as with Gode. +3. Supports more platforms, including Web. + ## Quick Start ### 1. Install the Plugin