@gjsify/node-gi 0.64.2 → 0.64.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -148,6 +148,15 @@ export halves, the legacy `imports.*` modules, cairo, and the measured
148
148
  divergences from GJS — is
149
149
  [docs/node-gi-gjs-surface.md](../../../docs/node-gi-gjs-surface.md).
150
150
 
151
+ One divergence is deliberate rather than measured, and it only exists under the
152
+ `nativescript` export condition: there `registerClass` accepts a parent class
153
+ that is not a GObject class, so an `@nativescript/core` widget can BE a real
154
+ GObject subclass (properties, signals, `bind_property`, `Gio.ListStore`
155
+ identity) while staying the widget its layout demands. Every other host throws
156
+ GJS's invalid-base-class error, unchanged — see
157
+ [§ `registerClass` over a FOREIGN parent class](../../../docs/node-gi-gjs-surface.md)
158
+ and [ADR 0105](../../../docs/adr/0105-real-gobject-backs-the-nativescript-subset-on-android.md).
159
+
151
160
  ## Runtimes (Node / Bun / Deno)
152
161
 
153
162
  The engine is a **Node-API** addon, so the same binary loads and runs on **Node,
package/gi.js CHANGED
@@ -11,7 +11,15 @@ import { ensureNative, onNativeLoaded } from './index.js';
11
11
  // Timers, `process` and the windowing env record come from the host seam (host.node.js
12
12
  // explains why they are the runtime's own, not the globals; host.nativescript.js is the
13
13
  // NativeScript Android variant).
14
- import { setImmediate, setInterval, clearInterval, runtimeProcess, windowingEnvWrites } from '#host';
14
+ import {
15
+ setImmediate,
16
+ setInterval,
17
+ clearInterval,
18
+ runtimeProcess,
19
+ windowingEnvWrites,
20
+ bundledLocaleDir,
21
+ FOREIGN_PARENT_CLASSES,
22
+ } from '#host';
15
23
  import { createGioDBus } from './overrides/gio-dbus.js';
16
24
  import { createGioSettings } from './overrides/gio-settings.js';
17
25
 
@@ -434,7 +442,7 @@ function wrapSignalCallback(cb) {
434
442
  // means `this` is the full userProto-carrying wrapper — the SAME cached proxy the user
435
443
  // constructed. Emitter at param 0, as in wrapSignalCallback.
436
444
  function resolveTemplateCallback(handle, handlerName) {
437
- return (...args) => {
445
+ const dispatcher = (...args) => {
438
446
  const proxy = wrapInstance(handle);
439
447
  const userProto = proxy[USER_PROTO];
440
448
  const desc = userProto !== undefined ? findProtoDescriptor(userProto, handlerName) : undefined;
@@ -445,6 +453,14 @@ function resolveTemplateCallback(handle, handlerName) {
445
453
  }
446
454
  return desc.value.apply(proxy, args.map(wrapReturn));
447
455
  };
456
+ // The native closure holds this dispatcher WEAKLY (src/signals.cc), and the
457
+ // dispatcher captures `handle` — so without a handle-keyed retain it would be
458
+ // garbage the moment create_closure returns, and the widget would be immortal.
459
+ // GJS's Gtk override reaches the same lifetime with
460
+ // `Gi.associateClosure(lifetimeObject, _createClosure(…))`. A fresh key per
461
+ // closure: one handler name can back several `<signal>` elements.
462
+ native.retainClosure(handle, Symbol(handlerName), dispatcher);
463
+ return dispatcher;
448
464
  }
449
465
  onNativeLoaded(() => native.setTemplateCallbackResolver(resolveTemplateCallback));
450
466
 
@@ -464,7 +480,12 @@ function runCtorForCObject(handle, gtypeName) {
464
480
  try {
465
481
  // No props: GtkBuilder already applied the construct properties to the C object;
466
482
  // the ctor's `params` arg is conventionally optional for a template child.
467
- Reflect.construct(cls, []);
483
+ const instance = Reflect.construct(cls, []);
484
+ // A FOREIGN-PARENT class has no makeClass base ctor to consume the latch, and its
485
+ // attach is lazy — so if the ctor body touched nothing, `adoptHandle` would still
486
+ // be set here and a later attach would build a SECOND GObject, orphaning the one
487
+ // C already created. Force the attach while the latch is still live.
488
+ if (adoptHandle !== undefined && isForeignInstance(instance)) foreignHandle(instance);
468
489
  } finally {
469
490
  adoptHandle = prev;
470
491
  }
@@ -1128,6 +1149,13 @@ function assignTemplateChildren(instance, handle, reg) {
1128
1149
  // outside what either runtime honours.
1129
1150
  const propertySettersByProto = new WeakMap();
1130
1151
 
1152
+ // The property setters THIS file generated for a foreign-parent class (route D, see the
1153
+ // FOREIGN-PARENT section). They must be invisible to resolvePropertySetter: a generated
1154
+ // setter's whole body is `native.setProperty`, and the engine answers every custom-property
1155
+ // store by calling runJsPropertySetter — which would resolve the generated setter and store
1156
+ // again, forever. Only a setter the CLASS declared is a JS setter worth replaying.
1157
+ const generatedForeignSetters = new WeakSet();
1158
+
1131
1159
  function findPropertySetter(userProto, propertyName) {
1132
1160
  let perProto = propertySettersByProto.get(userProto);
1133
1161
  if (perProto === undefined) propertySettersByProto.set(userProto, (perProto = new Map()));
@@ -1144,7 +1172,9 @@ function resolvePropertySetter(userProto, propertyName) {
1144
1172
  const camel = propertyName.replace(/-([a-z])/g, (m) => m[1].toUpperCase());
1145
1173
  for (const spelling of [propertyName, underscored, camel]) {
1146
1174
  const desc = findProtoDescriptor(userProto, spelling);
1147
- if (desc !== undefined && typeof desc.set === 'function') return desc.set;
1175
+ if (desc !== undefined && typeof desc.set === 'function' && !generatedForeignSetters.has(desc.set)) {
1176
+ return desc.set;
1177
+ }
1148
1178
  }
1149
1179
  return undefined;
1150
1180
  }
@@ -1260,6 +1290,7 @@ function flushPropertiesToJsSetters(instance, handle, reg) {
1260
1290
  // process loop) — for concurrent runtime-loop + GLib work there, drive GLib with
1261
1291
  // startMainContextPump instead of a blocking run.
1262
1292
  function applicationRunAsync(handle) {
1293
+ armBundledTextDomains(handle);
1263
1294
  return new Promise((resolve, reject) => {
1264
1295
  setImmediate(() => {
1265
1296
  try {
@@ -1271,6 +1302,37 @@ function applicationRunAsync(handle) {
1271
1302
  });
1272
1303
  }
1273
1304
 
1305
+ // ---- the GTK runtime bundle's own catalogs ----
1306
+ //
1307
+ // gtk_init, adw_init, gtk_source_init and GLib's first translated message each call
1308
+ // bindtextdomain(<domain>, <dir compiled into the build host>) — on the darwin bundle a
1309
+ // Homebrew Cellar path a user's Mac does not have, so GTK's own strings (Adw widgets, entry
1310
+ // context menus, shortcuts) stayed English under a German locale. Measured: the domains read
1311
+ // /opt/homebrew/Cellar/{gtk4,libadwaita}/… in a `startup` handler and NOT before `run()`, so
1312
+ // any earlier binding is undone. Armed on `register` and `run` (either emits `startup`), the
1313
+ // handler runs right after GApplication's
1314
+ // RUN_FIRST class closure (where those inits happen) and before `activate` builds the UI.
1315
+ // An app that builds widgets in its OWN startup handler connected before `run()` sees them
1316
+ // first, untranslated. Armed only when the loader found catalogs in the bundle.
1317
+ const BUNDLED_TEXT_DOMAINS = ['gtk40', 'libadwaita', 'glib20', 'gtksourceview-5'];
1318
+ const textDomainsArmed = new WeakSet();
1319
+
1320
+ function armBundledTextDomains(handle) {
1321
+ const dir = bundledLocaleDir();
1322
+ if (dir === undefined || textDomainsArmed.has(handle)) return;
1323
+ // `run` is not only GApplication's (GtkDialog, GTask, …), and only it emits `startup`.
1324
+ if (!native.isInstanceOf(handle, 'Gio', 'Application')) return;
1325
+ textDomainsArmed.add(handle);
1326
+ native.connectSignal(
1327
+ handle,
1328
+ 'startup',
1329
+ wrapSignalCallback(() => {
1330
+ for (const domain of BUNDLED_TEXT_DOMAINS) native.bindtextdomain(domain, dir);
1331
+ }),
1332
+ false,
1333
+ );
1334
+ }
1335
+
1274
1336
  // ---- portable GLib main-context pump (Bun/Deno) ----
1275
1337
  //
1276
1338
  // On Node the uv-in-GLib bridge co-pumps libuv during a blocking GLib loop, so a
@@ -1587,7 +1649,11 @@ function wrapInstance(handle, userProto) {
1587
1649
  // the leaf's OWN methods resolve). Never DOWNGRADE: a more-derived cached proto is
1588
1650
  // kept. The userProto lives on the target (read via the proxy here), so the
1589
1651
  // upgrade is in place — identity (===) and any expando fields are preserved.
1590
- if (userProto !== undefined && userProto !== cached[USER_PROTO]) {
1652
+ // NEVER re-link a foreign-parent instance (route D): linkInstanceProto does
1653
+ // `Object.setPrototypeOf`, and that object's [[Prototype]] IS its NativeScript
1654
+ // widget chain — replacing it would turn a real Label into something `_addView`
1655
+ // rejects. Its user prototype is already the registered one, fixed at attach.
1656
+ if (userProto !== undefined && userProto !== cached[USER_PROTO] && !isForeignInstance(cached)) {
1591
1657
  const current = cached[USER_PROTO];
1592
1658
  if (current === undefined || isProtoSameOrDescendantOf(userProto, current)) {
1593
1659
  linkInstanceProto(cached, userProto);
@@ -1643,7 +1709,10 @@ function wrapInstance(handle, userProto) {
1643
1709
  let thunk = giMethodThunks[prop];
1644
1710
  if (thunk === undefined) {
1645
1711
  thunk = instanceHasMethod(handle, prop)
1646
- ? (...args) => wrapReturn(native.callMethod(handle, prop, unwrapArgs(args)))
1712
+ ? (...args) => {
1713
+ if (prop === 'run' || prop === 'register') armBundledTextDomains(handle);
1714
+ return wrapReturn(native.callMethod(handle, prop, unwrapArgs(args)));
1715
+ }
1647
1716
  : null;
1648
1717
  giMethodThunks[prop] = thunk;
1649
1718
  }
@@ -1946,6 +2015,7 @@ function makeGiMethod(where, method, arity) {
1946
2015
  // the registration is honoured here, where the instance is known.
1947
2016
  const promisified = promisifiedMethods.size === 0 ? undefined : resolveRegistered(handle, method);
1948
2017
  if (promisified !== undefined) return promisified.apply(this, args);
2018
+ if (method === 'run' || method === 'register') armBundledTextDomains(handle);
1949
2019
  return wrapReturn(native.callMethod(handle, method, unwrapArgs(args)));
1950
2020
  };
1951
2021
  nativeThunks.add(fn);
@@ -2612,7 +2682,11 @@ function findParentGType(klass) {
2612
2682
  for (let c = Object.getPrototypeOf(klass); typeof c === 'function'; c = Object.getPrototypeOf(c)) {
2613
2683
  const reg = registeredClasses.get(c);
2614
2684
  if (reg !== undefined) {
2615
- return { parentGTypeHandle: reg.typeHandle };
2685
+ // `parentForeign` carries the FOREIGN-PARENT marker down the chain: a subclass
2686
+ // of `registerClass(…, class extends Label {})` has a perfectly ordinary
2687
+ // registered parent by this test, yet ITS instances are still NativeScript
2688
+ // objects that need the attach path rather than the wrapper Proxy.
2689
+ return { parentGTypeHandle: reg.typeHandle, parentForeign: reg.foreign === true };
2616
2690
  }
2617
2691
  const gt = c.$gtypeName;
2618
2692
  if (typeof gt === 'string') {
@@ -2620,9 +2694,19 @@ function findParentGType(klass) {
2620
2694
  if (dot > 0) {
2621
2695
  return { parentNamespace: gt.slice(0, dot), parentType: gt.slice(dot + 1) };
2622
2696
  }
2623
- // A dotless `$gtypeName` with no registeredClasses entry should never occur
2624
- // (only registered types are dotless, and they are in the registry); be
2625
- // explicit rather than mis-resolving the namespace split.
2697
+ // A dotless `$gtypeName` and no registry entry, with the name INHERITED: `c` is
2698
+ // an ordinary unregistered subclass of a registered ancestor further up, reading
2699
+ // that ancestor's static through the class-side prototype chain. Keep walking —
2700
+ // `c` contributes no GType, and the registered ancestor above it is the parent.
2701
+ // gjs answers the same way (measured, 1.88.1): `registerClass(C extends B)` where
2702
+ // `class B extends RegisteredA {}` gives `type_parent(C) === A`. Without this the
2703
+ // walk threw on its own inherited value, which is every port class that puts a
2704
+ // plain subclass between two registrations — `AdwSwitchRow` between the two
2705
+ // route-D mixins (ADR 0105 § 2) is the case that found it.
2706
+ if (!Object.prototype.hasOwnProperty.call(c, '$gtypeName')) continue;
2707
+ // An OWN dotless `$gtypeName` with no registry entry should never occur (only
2708
+ // registered types are dotless, and they are in the registry); be explicit
2709
+ // rather than mis-resolving the namespace split.
2626
2710
  throw new TypeError("GObject.registerClass: parent '" + gt + "' has no resolvable GType");
2627
2711
  }
2628
2712
  }
@@ -2787,6 +2871,335 @@ function collectVfuncs(klass) {
2787
2871
  * @param {Function} [maybeClass]
2788
2872
  * @returns {Function} the same `klass` (now registered)
2789
2873
  */
2874
+ // ---- FOREIGN-PARENT classes: a registered GObject attached to a host widget ----
2875
+ //
2876
+ // THE ONE DELIBERATE DIVERGENCE FROM GJS, and it only exists where GJS cannot run.
2877
+ // Gated on `FOREIGN_PARENT_CLASSES` (#host): true on NativeScript, false everywhere
2878
+ // else, where registerClass keeps throwing GJS's invalid-base error verbatim. See
2879
+ // host.nativescript.js for why that gate is sound, ADR 0105 § 2 for the decision and
2880
+ // docs/node-gi-gjs-surface.md for the surface this adds.
2881
+ //
2882
+ // What it buys: `registerClass({GTypeName, Properties, Signals}, class MyLabel extends
2883
+ // Label {})` where `Label` is a `@nativescript/core` widget. The JS object stays a REAL
2884
+ // Label — its [[Prototype]] is the untouched NativeScript chain — so `stack.addChild(w)`,
2885
+ // `ContentView.content = w` and `_addChildFromBuilder` pass their hard `instanceof
2886
+ // ViewBase` gates with no `.view` unwrap anywhere in consumer code. Beside it sits a real
2887
+ // GObject of a registered GType whose GType PARENT is GObject.Object (the GType parent is
2888
+ // independent of the JS prototype chain), so the object also answers `instanceof
2889
+ // GObject.Object`, carries custom properties with `notify::`, and emits registered signals.
2890
+ //
2891
+ // ATTACH, never wrap. Everywhere else node-gi hands back a Proxy whose get/set traps
2892
+ // resolve the GObject surface dynamically; here there is no Proxy to put traps on, so:
2893
+ // • `[HANDLE]`/`[USER_PROTO]` become non-enumerable OWN props of the widget object
2894
+ // (`Symbol.hasInstance` resolves `instanceof` through `instance[HANDLE]`, which is
2895
+ // what makes the widget a GObject.Object);
2896
+ // • everything the traps would have answered — connect/emit/notify, the property
2897
+ // accessors, the GObject.Object methods — is installed as OWN members of
2898
+ // `klass.prototype`, once, at registerClass time;
2899
+ // • `Object.setPrototypeOf` is never called on such an object (see linkInstanceProto).
2900
+ //
2901
+ // LAZY. The GObject is constructed on the first access that needs it (`foreignHandle`),
2902
+ // because nothing in a NativeScript widget's construction runs node-gi code: there is no
2903
+ // base ctor to route `new` through the way makeClass does. Every entry point goes through
2904
+ // `foreignHandle`, including `unwrapArg` reading `[HANDLE]` through the prototype getter,
2905
+ // so by the time anything can OBSERVE the GObject it exists — `new MyLabel() instanceof
2906
+ // GObject.Object` attaches and answers true. Construct properties are the one thing this
2907
+ // cannot carry (the widget ctor owns its own arguments); set them after construction.
2908
+ //
2909
+ // NOT supported, deliberately: `vfunc_*` overrides. collectVfuncs finds none on a widget
2910
+ // chain, and a vfunc trampoline would need to re-link the instance prototype — the one
2911
+ // thing this path must never do.
2912
+ const FOREIGN_MATERIALISED = Symbol('nodeGiForeignMaterialised');
2913
+
2914
+ // A registered foreign-parent class's PROTOTYPE → its registration. The instance carries
2915
+ // no class pointer we can trust (its `constructor` is the widget's), so the attach walks
2916
+ // the real prototype chain and takes the nearest entry — which is the LEAF's, since a
2917
+ // subclass's own prototype comes first.
2918
+ const foreignRegByProto = new WeakMap();
2919
+
2920
+ // Gated first, so on a host where foreign parents cannot exist this is one boolean read
2921
+ // rather than a prototype-chain walk — it sits on wrapInstance's upgrade path.
2922
+ function isForeignInstance(obj) {
2923
+ return FOREIGN_PARENT_CLASSES && foreignRegFor(obj) !== undefined;
2924
+ }
2925
+
2926
+ function foreignRegFor(obj) {
2927
+ for (let p = Object.getPrototypeOf(obj); p !== null; p = Object.getPrototypeOf(p)) {
2928
+ const reg = foreignRegByProto.get(p);
2929
+ if (reg !== undefined) return reg;
2930
+ }
2931
+ return undefined;
2932
+ }
2933
+
2934
+ // Construct this object's GObject and bind the two together. Not a wrap: the object is
2935
+ // handed back unchanged apart from three non-enumerable own symbols.
2936
+ function attachForeignGObject(obj, reg) {
2937
+ let handle;
2938
+ if (adoptHandle !== undefined) {
2939
+ // A C-created instance (runCtorForCObject) — the engine already built the GObject.
2940
+ // Consume the latch atomically, exactly as the makeClass base ctor does.
2941
+ handle = adoptHandle;
2942
+ adoptHandle = undefined;
2943
+ } else {
2944
+ handle = native.constructType(reg.typeHandle, {});
2945
+ }
2946
+ // HANDLE first: it shadows the prototype getter, so anything the flush below runs can
2947
+ // re-enter `foreignHandle` without constructing a second GObject.
2948
+ Object.defineProperty(obj, HANDLE, { value: handle, configurable: true });
2949
+ Object.defineProperty(obj, USER_PROTO, { value: reg.foreignProto, configurable: true });
2950
+ instanceCache.set(handle, obj);
2951
+ flushPropertiesToJsSetters(obj, handle, reg);
2952
+ return handle;
2953
+ }
2954
+
2955
+ // This object's GObject handle, attaching on first demand. Every member installed below
2956
+ // goes through here, as does `unwrapArg` (via the prototype [HANDLE] getter).
2957
+ function foreignHandle(obj) {
2958
+ if (Object.hasOwn(obj, HANDLE)) return obj[HANDLE];
2959
+ const reg = foreignRegFor(obj);
2960
+ if (reg === undefined) {
2961
+ throw new TypeError('node-gi: this object is not an instance of a GObject.registerClass()-registered class');
2962
+ }
2963
+ return attachForeignGObject(obj, reg);
2964
+ }
2965
+
2966
+ // Which custom properties of this instance hold a materialised value — the state GJS's
2967
+ // generated accessors keep in their private field, and what the equal-value guard
2968
+ // compares against. Same rule as the wrapper's `materialise` (see wrapInstance): seeded
2969
+ // from what the engine has stored, then extended by every read and write.
2970
+ function foreignMaterialise(obj, name) {
2971
+ let seen = obj[FOREIGN_MATERIALISED];
2972
+ if (seen === undefined) {
2973
+ seen = new Set(typeof native.storedPropertyNames === 'function' ? native.storedPropertyNames(obj[HANDLE]) : []);
2974
+ Object.defineProperty(obj, FOREIGN_MATERIALISED, { value: seen, configurable: true });
2975
+ }
2976
+ const had = seen.has(name);
2977
+ seen.add(name);
2978
+ return had;
2979
+ }
2980
+
2981
+ // The GObject.Object instance methods installed as callMethod thunks. A MODEST list on
2982
+ // purpose — only introspectable, GJS-reachable members with no gpointer/GValue-array
2983
+ // argument (`g_object_get_data`, `getv` and friends are deliberately absent rather than
2984
+ // present and throwing). The wrapper Proxy answers these from introspection dynamically;
2985
+ // this path has to name them.
2986
+ const FOREIGN_OBJECT_METHODS = [
2987
+ 'get_property',
2988
+ 'set_property',
2989
+ 'bind_property',
2990
+ 'notify_by_pspec',
2991
+ 'freeze_notify',
2992
+ 'thaw_notify',
2993
+ 'run_dispose',
2994
+ 'ref',
2995
+ 'unref',
2996
+ 'ref_sink',
2997
+ 'force_floating',
2998
+ 'is_floating',
2999
+ ];
3000
+
3001
+ function snakeToCamel(name) {
3002
+ return name.replace(/_([a-z])/g, (m) => m[1].toUpperCase());
3003
+ }
3004
+
3005
+ /** The distinct spellings a member is installed under: one entry for an underscore-free name. */
3006
+ function memberSpellings(name) {
3007
+ const camel = snakeToCamel(name);
3008
+ return camel === name ? [name] : [name, camel];
3009
+ }
3010
+
3011
+ // Install a member under `name` unless the host chain already answers under it — a
3012
+ // widget's own method ALWAYS wins, because consumer code and NativeScript's own
3013
+ // internals call it. Returns false (and the caller records the skip) when it loses.
3014
+ function defineForeignMember(proto, name, descriptor, skipped) {
3015
+ if (findProtoDescriptor(proto, name) !== undefined) {
3016
+ skipped.push(name);
3017
+ return false;
3018
+ }
3019
+ Object.defineProperty(proto, name, { configurable: true, enumerable: false, ...descriptor });
3020
+ return true;
3021
+ }
3022
+
3023
+ // Everything the instance wrapper's traps would have resolved, as own members of a
3024
+ // foreign-parent class's prototype. Returns the names the host chain already owned, so a
3025
+ // program (and the route-D spec) can see exactly what it did NOT get.
3026
+ function installForeignGObjectSurface(proto, properties) {
3027
+ const skipped = [];
3028
+
3029
+ // `[HANDLE]` as a lazily-attaching GETTER is the whole load-bearing trick: it makes
3030
+ // `instanceof GObject.Object`, `unwrapArg` and every member below attach on demand,
3031
+ // with no construction hook on the widget side. An attach replaces it with an own
3032
+ // data property, so this getter runs at most once per instance.
3033
+ Object.defineProperty(proto, HANDLE, {
3034
+ configurable: true,
3035
+ get() {
3036
+ return foreignHandle(this);
3037
+ },
3038
+ });
3039
+
3040
+ defineForeignMember(
3041
+ proto,
3042
+ '$typeName',
3043
+ {
3044
+ get() {
3045
+ return native.getTypeName(foreignHandle(this));
3046
+ },
3047
+ },
3048
+ skipped,
3049
+ );
3050
+
3051
+ // Signal API. The same four thunks the wrapper builds, including the (fn → ids)
3052
+ // bookkeeping signal_handlers_*_by_func resolves through.
3053
+ defineForeignMember(
3054
+ proto,
3055
+ 'connect',
3056
+ {
3057
+ value: function connect(signal, cb) {
3058
+ const handle = foreignHandle(this);
3059
+ const id = native.connectSignal(handle, signal, wrapSignalCallback(cb), false);
3060
+ recordSignalHandler(handle, cb, id);
3061
+ return id;
3062
+ },
3063
+ },
3064
+ skipped,
3065
+ );
3066
+ const connectAfter = function connect_after(signal, cb) {
3067
+ const handle = foreignHandle(this);
3068
+ const id = native.connectSignal(handle, signal, wrapSignalCallback(cb), true);
3069
+ recordSignalHandler(handle, cb, id);
3070
+ return id;
3071
+ };
3072
+ defineForeignMember(proto, 'connect_after', { value: connectAfter }, skipped);
3073
+ defineForeignMember(proto, 'connectAfter', { value: connectAfter }, skipped);
3074
+ defineForeignMember(
3075
+ proto,
3076
+ 'disconnect',
3077
+ {
3078
+ value: function disconnect(id) {
3079
+ const handle = foreignHandle(this);
3080
+ forgetSignalHandlerId(handle, id);
3081
+ return native.disconnectSignal(handle, id);
3082
+ },
3083
+ },
3084
+ skipped,
3085
+ );
3086
+ defineForeignMember(
3087
+ proto,
3088
+ 'emit',
3089
+ {
3090
+ value: function emit(signal, ...args) {
3091
+ return wrapReturn(native.emitSignal(foreignHandle(this), signal, unwrapArgs(args)));
3092
+ },
3093
+ },
3094
+ skipped,
3095
+ );
3096
+
3097
+ // `notify` is the ONE name that cannot be skipped and cannot be taken: NativeScript's
3098
+ // Observable.prototype.notify({eventName, object}) is called by the widget machinery
3099
+ // itself (and by consumer code), while GObject's notify(propertyName) is what a
3100
+ // custom property's setter needs. They are told apart by ARGUMENT TYPE — a string is
3101
+ // a GObject property name, anything else is a NativeScript event payload — and the
3102
+ // inherited implementation is captured HERE, before this definition shadows it, so
3103
+ // both keep working on one object. This is the only collision in the
3104
+ // @nativescript/core 9.1 Label chain (measured: Observable.prototype.notify is the
3105
+ // sole hit for the whole installed surface).
3106
+ const inheritedNotify = findProtoDescriptor(proto, 'notify');
3107
+ const hostNotify = inheritedNotify !== undefined ? inheritedNotify.value : undefined;
3108
+ Object.defineProperty(proto, 'notify', {
3109
+ configurable: true,
3110
+ enumerable: false,
3111
+ value: function notify(arg, ...rest) {
3112
+ if (typeof arg === 'string') {
3113
+ return wrapReturn(native.callMethod(foreignHandle(this), 'notify', [arg]));
3114
+ }
3115
+ if (typeof hostNotify === 'function') return hostNotify.call(this, arg, ...rest);
3116
+ throw new TypeError('notify(): expected a GObject property name');
3117
+ },
3118
+ });
3119
+
3120
+ for (const name of FOREIGN_OBJECT_METHODS) {
3121
+ const thunk = function giMethod(...args) {
3122
+ return wrapReturn(native.callMethod(foreignHandle(this), name, unwrapArgs(args)));
3123
+ };
3124
+ // `memberSpellings(name)` and not `[name, snakeToCamel(name)]`: a name with no
3125
+ // underscore camel-cases to ITSELF (`ref`, `unref`), so the second call would
3126
+ // find the descriptor the first one just installed and record a skip against
3127
+ // the host for a member the host never had. `$foreignSkipped` is read as "what
3128
+ // did the widget already own", so a self-skip there is a false statement.
3129
+ for (const spelling of memberSpellings(name)) {
3130
+ defineForeignMember(proto, spelling, { value: thunk }, skipped);
3131
+ }
3132
+ }
3133
+
3134
+ // gjs's GObject.Object.prototype shims over namespace-level g_signal_* /
3135
+ // bind_property_full — not introspected instance methods, so named explicitly here
3136
+ // just as the wrapper's get trap routes them explicitly.
3137
+ for (const name of [
3138
+ 'block_signal_handler',
3139
+ 'unblock_signal_handler',
3140
+ 'stop_emission_by_name',
3141
+ 'bind_property_full',
3142
+ ]) {
3143
+ for (const spelling of memberSpellings(name)) {
3144
+ const shim = objectPrototypeShim(spelling);
3145
+ defineForeignMember(
3146
+ proto,
3147
+ spelling,
3148
+ {
3149
+ value: function shimmed(...args) {
3150
+ return shim(foreignHandle(this), args);
3151
+ },
3152
+ },
3153
+ skipped,
3154
+ );
3155
+ }
3156
+ }
3157
+
3158
+ installForeignPropertyAccessors(proto, properties, skipped);
3159
+ return skipped;
3160
+ }
3161
+
3162
+ // GJS-style generated accessors for the DECLARED custom properties, under all three
3163
+ // spellings GJS makes equivalent (dash, underscore, camel — `_checkAccessors` mirrors the
3164
+ // declared one onto the other two). Elsewhere node-gi installs none and resolves the
3165
+ // spellings at the point of use instead (findPropertySetter), because the wrapper Proxy
3166
+ // can; this path has no Proxy, so the accessors have to exist. A ROUTE-D-ONLY divergence
3167
+ // from the rest of node-gi, and the reason the equal-value guard is replicated here.
3168
+ //
3169
+ // A spelling the host chain already answers is left ALONE: NativeScript defines `text`,
3170
+ // `width`, … on its own prototypes with real setters driving the native view, and
3171
+ // shadowing one would break the widget. The engine's store still routes to it —
3172
+ // runJsPropertySetter resolves the class-or-chain setter — so declaring a GObject
3173
+ // property named after a widget property composes instead of colliding.
3174
+ function installForeignPropertyAccessors(proto, properties, skipped) {
3175
+ for (const spec of properties) {
3176
+ const name = spec.name;
3177
+ if (typeof name !== 'string' || name.length === 0) continue;
3178
+ const spellings = new Set([
3179
+ name,
3180
+ name.replace(/-/g, '_'),
3181
+ name.replace(/-([a-z])/g, (m) => m[1].toUpperCase()),
3182
+ ]);
3183
+ const get = function () {
3184
+ const handle = foreignHandle(this);
3185
+ foreignMaterialise(this, name);
3186
+ return wrapReturn(native.getProperty(handle, name));
3187
+ };
3188
+ const set = function (value) {
3189
+ const handle = foreignHandle(this);
3190
+ // Equal-value guard, GJS's _generateAccessors setter verbatim — see the same
3191
+ // guard in wrapInstance's set trap for why it is only armed once the value
3192
+ // has been materialised.
3193
+ if (foreignMaterialise(this, name) && wrapReturn(native.getProperty(handle, name)) === value) return;
3194
+ native.setProperty(handle, name, unwrapArg(value));
3195
+ };
3196
+ generatedForeignSetters.add(set);
3197
+ for (const spelling of spellings) {
3198
+ defineForeignMember(proto, spelling, { get, set }, skipped);
3199
+ }
3200
+ }
3201
+ }
3202
+
2790
3203
  // GJS's meta FIELD form: a class may declare its meta as static symbol fields
2791
3204
  // (`Klass[GObject.GTypeName] = 'X'`) instead of passing a meta object, and GJS treats
2792
3205
  // the symbols as the real source — `registerClass(meta, klass)` only copies meta onto
@@ -2826,13 +3239,19 @@ function registerClass(metaOrClass, maybeClass) {
2826
3239
  meta = { ...fieldMeta, ...meta };
2827
3240
 
2828
3241
  const parent = findParentGType(klass);
2829
- if (parent === undefined) {
3242
+ // A parent that is not a node-gi GObject class at all. GJS throws here
3243
+ // ("registerClass() used with invalid base class", refs/gjs GObject.js) and so does
3244
+ // every host but NativeScript — where the FOREIGN-PARENT path instead registers the
3245
+ // GType under GObject.Object and ATTACHES it to the host widget object. See the
3246
+ // FOREIGN-PARENT section above.
3247
+ if (parent === undefined && !FOREIGN_PARENT_CLASSES) {
2830
3248
  throw new TypeError(
2831
3249
  'GObject.registerClass: ' +
2832
3250
  (klass.name || 'the class') +
2833
3251
  ' must extend a node-gi GObject class (e.g. GObject.Object)',
2834
3252
  );
2835
3253
  }
3254
+ const foreign = parent === undefined || parent.parentForeign === true;
2836
3255
 
2837
3256
  const properties = [];
2838
3257
  if (meta.Properties !== null && typeof meta.Properties === 'object') {
@@ -2874,10 +3293,16 @@ function registerClass(metaOrClass, maybeClass) {
2874
3293
  // from its runtime GType handle (it has no introspection entry); an INTROSPECTED
2875
3294
  // parent is resolved by namespace+type. Construction (the leaf's registered GType
2876
3295
  // via the makeClass new.target routing) is already multi-level-correct either way.
3296
+ // A FOREIGN parent contributes no GType at all (it is a host widget class), so the
3297
+ // registered type is rooted at GObject.Object — the GType parent is independent of
3298
+ // the JS prototype chain, which is exactly what makes route D possible. A foreign
3299
+ // SUBCLASS still has a registered parent and takes the ordinary by-GType branch.
2877
3300
  const typeHandle =
2878
- parent.parentGTypeHandle !== undefined
2879
- ? native.registerClassFromGType(gtypeName, parent.parentGTypeHandle, options)
2880
- : native.registerClass(gtypeName, parent.parentNamespace, parent.parentType, options);
3301
+ parent === undefined
3302
+ ? native.registerClass(gtypeName, 'GObject', 'Object', options)
3303
+ : parent.parentGTypeHandle !== undefined
3304
+ ? native.registerClassFromGType(gtypeName, parent.parentGTypeHandle, options)
3305
+ : native.registerClass(gtypeName, parent.parentNamespace, parent.parentType, options);
2881
3306
 
2882
3307
  // G3: register the GType for the GIVEN class IN PLACE (no throwaway Subclass) and
2883
3308
  // return that same class. `$gtypeName`/`$gtype` are defined as OWN properties:
@@ -2917,6 +3342,8 @@ function registerClass(metaOrClass, maybeClass) {
2917
3342
  children: children.length > 0 ? children : undefined,
2918
3343
  internalChildren: internalChildren.length > 0 ? internalChildren : undefined,
2919
3344
  constructPropertyNames: constructPropertyNames.length > 0 ? constructPropertyNames : undefined,
3345
+ foreign: foreign ? true : undefined,
3346
+ foreignProto: foreign ? klass.prototype : undefined,
2920
3347
  });
2921
3348
  // Which properties the equal-value guard owns: the ones DECLARED here, union the
2922
3349
  // registered parent's (a G2 subclass inherits its parent's custom properties and
@@ -2933,6 +3360,36 @@ function registerClass(metaOrClass, maybeClass) {
2933
3360
  // Reverse index for runCtorForCObject: the engine identifies a C-created instance
2934
3361
  // by its GType name (= gtypeName, what native.registerClass registered).
2935
3362
  classesByGType.set(gtypeName, klass);
3363
+ // FOREIGN parent: install the GObject surface as own members of this prototype and
3364
+ // index the prototype, so an instance can find ITS leaf registration by walking the
3365
+ // real (host-widget) prototype chain. Both before type_class_ref, like every record
3366
+ // above it. `foreignSkipped` names the members the host chain already owned — the
3367
+ // honest answer to "what did this class NOT get", which the route-D spec reports.
3368
+ if (foreign) {
3369
+ const reg = registeredClasses.get(klass);
3370
+ reg.foreignSkipped = installForeignGObjectSurface(klass.prototype, properties);
3371
+ foreignRegByProto.set(klass.prototype, reg);
3372
+ }
3373
+ // `$foreignSkipped`, readable from outside, because "which GObject spelling did the
3374
+ // host widget already own?" is the one question this route cannot answer in the
3375
+ // abstract: it depends on the parent class, so it has to be reported per
3376
+ // registration rather than documented once.
3377
+ //
3378
+ // On EVERY registration of this host, not only the foreign ones, and an empty array
3379
+ // where nothing was skipped. A class's static side is a Proxy that answers any
3380
+ // unknown name with a static-method thunk, so `X.$foreignSkipped` is already
3381
+ // truthy — a FUNCTION — wherever the property is absent, and `if
3382
+ // (X.$foreignSkipped.length)` would then throw rather than read "nothing". Defining
3383
+ // it is what makes the read mean what it says. Gated on the host flag all the same,
3384
+ // so the default host's registerClass keeps exactly GJS's static surface.
3385
+ if (FOREIGN_PARENT_CLASSES) {
3386
+ Object.defineProperty(klass, '$foreignSkipped', {
3387
+ value: Object.freeze([...(registeredClasses.get(klass).foreignSkipped ?? [])]),
3388
+ configurable: true,
3389
+ enumerable: false,
3390
+ writable: false,
3391
+ });
3392
+ }
2936
3393
  // GJS parity: registration must be COMPLETE when registerClass returns. The
2937
3394
  // engine installs the custom properties, signals and vfunc trampolines in
2938
3395
  // class_init (src/class.cc), which GObject runs lazily on the first
@@ -3025,6 +3482,12 @@ function signalEmitByName(object, ...nameAndArgs) {
3025
3482
  // makeClass). Merged enums are cached so identity is stable.
3026
3483
  const OVERLAY_NAMES = new Set([
3027
3484
  'registerClass',
3485
+ // Whether THIS host registers a class over a non-GObject parent (the FOREIGN-PARENT
3486
+ // section above) or throws on one. A caller that wants route D has no other way to
3487
+ // ask: the only alternative is to register and catch, which registers a GType as a
3488
+ // side effect of a question and turns "this host cannot" into the same outcome as
3489
+ // "this registration was wrong". Read by @gjsify/adwaita-gobject-native's engine.
3490
+ '$foreignParentClasses',
3028
3491
  'GTypeName',
3029
3492
  'properties',
3030
3493
  'signals',
@@ -3094,6 +3557,7 @@ function decorateGObjectNamespace(baseNs) {
3094
3557
  if (cache.has(prop)) return cache.get(prop);
3095
3558
  let value;
3096
3559
  if (prop === 'registerClass') value = registerClass;
3560
+ else if (prop === '$foreignParentClasses') value = FOREIGN_PARENT_CLASSES;
3097
3561
  else if (prop === 'GTypeName') value = GTypeNameSymbol;
3098
3562
  else if (prop === 'properties') value = propertiesSymbol;
3099
3563
  else if (prop === 'signals') value = signalsSymbol;
package/gtk-runtime.js CHANGED
@@ -46,6 +46,14 @@ let windowingEnvWritesMade = [];
46
46
  * Empty off win32 (POSIX `setenv()` has one environment) and empty when the wiring no-opped.
47
47
  * @returns {Array<[string, string]>}
48
48
  */
49
+ /** Where the windowing bundle's own catalogs live, or `undefined` (see `armBundledTextDomains` in gi.js). */
50
+ let bundleLocaleDir;
51
+
52
+ /** @returns {string | undefined} */
53
+ export function bundledLocaleDir() {
54
+ return bundleLocaleDir;
55
+ }
56
+
49
57
  export function windowingEnvWrites() {
50
58
  return process.platform === 'win32' ? windowingEnvWritesMade.slice() : [];
51
59
  }
@@ -484,6 +492,14 @@ export function maybeWireGtkWindowingEnv() {
484
492
  }
485
493
  }
486
494
 
495
+ // The catalogs the bundle ships for its own libraries. Binding them HERE would be undone:
496
+ // gtk_init, adw_init and GLib's first translated message each call
497
+ // bindtextdomain(<domain>, <path compiled into the build host>) themselves, and that path
498
+ // does not exist on a Mac without Homebrew. So only the directory is recorded here;
499
+ // gi.js re-binds the domains once those inits have run (armBundledTextDomains).
500
+ const localeDir = join(bundle.dir, 'share', 'locale');
501
+ bundleLocaleDir = existsSync(localeDir) ? localeDir : undefined;
502
+
487
503
  const fontsConf = join(bundle.dir, 'etc', 'fonts', 'fonts.conf');
488
504
  if (existsSync(fontsConf)) {
489
505
  setIfUnset('FONTCONFIG_PATH', join(bundle.dir, 'etc', 'fonts'));
@@ -50,6 +50,19 @@ export const RUNTIME = 'nativescript';
50
50
  */
51
51
  export const LAZY_NATIVE = true;
52
52
 
53
+ /**
54
+ * Whether `GObject.registerClass` may register a class whose JS parent is NOT a node-gi
55
+ * GObject class — `class MyLabel extends Label {}`, where `Label` is a
56
+ * `@nativescript/core` widget. GJS rejects that outright and so does node-gi on every
57
+ * other host; this is the ONE deliberate divergence, and it is deliberate because on
58
+ * NativeScript there is no GJS to be compatible with: node-gi is the only GI runtime that
59
+ * ever executes here, so a program written against this cannot be a program that behaves
60
+ * differently under GJS. See the FOREIGN-PARENT section in gi.js, ADR 0105 § 2 and
61
+ * docs/node-gi-gjs-surface.md.
62
+ * @type {boolean}
63
+ */
64
+ export const FOREIGN_PARENT_CLASSES = true;
65
+
53
66
  // Timers from the global scope: NativeScript has no node:timers, and there is no
54
67
  // GLib-backed replacement to avoid here. setImmediate is not a V8 builtin.
55
68
  export const setImmediate =
@@ -86,6 +99,10 @@ export function windowingEnvWrites() {
86
99
  return [];
87
100
  }
88
101
 
102
+ export function bundledLocaleDir() {
103
+ return undefined;
104
+ }
105
+
89
106
  // NativeScript's runtime class hands out the Application without @nativescript/core
90
107
  // (android.app.ActivityThread is a hidden API and absent from the metadata).
91
108
  function deriveAppDir() {
package/host.node.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  maybeReexecForGtkRuntime,
19
19
  maybeWireGtkWindowingEnv,
20
20
  windowingEnvWrites,
21
+ bundledLocaleDir,
21
22
  } from './gtk-runtime.js';
22
23
  import { activateNativePrebuilds } from './native-prebuilds.js';
23
24
  // From node:timers, not the globals, for two reasons: setImmediate is a Node/Bun global
@@ -31,7 +32,7 @@ import { setImmediate, setInterval, clearInterval } from 'node:timers';
31
32
  // loop-liveness hooks in gi.js would silently never fire.
32
33
  import runtimeProcess from 'node:process';
33
34
 
34
- export { setImmediate, setInterval, clearInterval, runtimeProcess, windowingEnvWrites };
35
+ export { setImmediate, setInterval, clearInterval, runtimeProcess, windowingEnvWrites, bundledLocaleDir };
35
36
 
36
37
  const require = createRequire(import.meta.url);
37
38
  const here = packageRoot;
@@ -61,6 +62,16 @@ export const RUNTIME =
61
62
  */
62
63
  export const LAZY_NATIVE = false;
63
64
 
65
+ /**
66
+ * Whether `GObject.registerClass` may register a class whose JS parent is NOT a node-gi
67
+ * GObject class. FALSE here, and that is the point: on Node/Bun/Deno/GJS a program can be
68
+ * run under real GJS, so registerClass must keep throwing GJS's invalid-base error
69
+ * (refs/gjs/modules/core/overrides/GObject.js) rather than quietly accept a class GJS
70
+ * would reject. Only host.nativescript.js enables it — see the note there.
71
+ * @type {boolean}
72
+ */
73
+ export const FOREIGN_PARENT_CLASSES = false;
74
+
64
75
  // GJS host mode: `GjsifyNapi.init()` installs the loader as
65
76
  // `globalThis.__gjsifyNapiLoadAddon` and re-installs it if another consumer (the
66
77
  // @gjsify/napi L1) already captured + deleted it. Needs the GjsifyNapi typelib
package/index.d.ts CHANGED
@@ -542,6 +542,18 @@ export function emitSignal(handle: GObjectHandle, signalName: string, args?: unk
542
542
  /** Disconnect a previously connected signal handler. */
543
543
  export function disconnectSignal(handle: GObjectHandle, handlerId: number): void;
544
544
 
545
+ /**
546
+ * INTERNAL. Keep a callback alive for as long as `handle` is alive. Signal and
547
+ * template-callback GClosures hold their JS callback through a WEAK napi_ref, so
548
+ * a connected handler is not a GC root (which is what makes a handler capturing
549
+ * its own emitter collectable, as in GJS); this handle-keyed WeakMap is the
550
+ * ephemeron that keeps it callable meanwhile. `connectSignal` does it for you.
551
+ */
552
+ export function retainClosure(handle: GObjectHandle, key: number | symbol, fn: Function): void;
553
+
554
+ /** INTERNAL. Drop a closure retained by {@link retainClosure}. */
555
+ export function releaseClosure(handle: GObjectHandle, key: number | symbol): void;
556
+
545
557
  /**
546
558
  * Register the L1 resolver mapping a Gtk.Template `<signal handler="…">` handler
547
559
  * name to the instance's bound JS method (engine template-callback scope).
package/index.js CHANGED
@@ -94,9 +94,16 @@ export function ensureNative() {
94
94
  makePumpPendingCount = addon.makePumpPendingCount;
95
95
  pumpKick = addon.pumpKick;
96
96
  setMicrotaskDrain = addon.setMicrotaskDrain;
97
- connectSignal = addon.connectSignal;
98
97
  emitSignal = addon.emitSignal;
99
- disconnectSignal = addon.disconnectSignal;
98
+ connectSignal = (handle, signalName, callback, after) => {
99
+ const id = addon.connectSignal(handle, signalName, callback, after);
100
+ retainClosure(handle, id, callback);
101
+ return id;
102
+ };
103
+ disconnectSignal = (handle, handlerId) => {
104
+ releaseClosure(handle, handlerId);
105
+ return addon.disconnectSignal(handle, handlerId);
106
+ };
100
107
  setTemplateCallbackResolver = addon.setTemplateCallbackResolver;
101
108
  setConstructCallback = addon.setConstructCallback;
102
109
  setPropertySetCallback = addon.setPropertySetCallback;
@@ -205,6 +212,52 @@ export let mainContextHasPending;
205
212
  export let makePumpPendingCount;
206
213
  export let pumpKick;
207
214
  export let setMicrotaskDrain;
215
+ // ---- signal-closure keep-alive (GJS's associate_closure, built in JS) ----
216
+ //
217
+ // A signal / template-callback GClosure holds its JS callback through a WEAK
218
+ // napi_ref (src/signals.cc), so a C-held closure is NOT a GC root. That is what
219
+ // makes `obj.connect('x', () => obj.y)` collectable instead of immortal, and it
220
+ // is GJS's design: its signal closures keep the callable unrooted and TRACE it
221
+ // from the owning wrapper (ObjectInstance::m_closures).
222
+ //
223
+ // node-gi has no tracing hook into V8, so the same edge is expressed as the one
224
+ // ephemeron a non-extensible External can take part in: a WeakMap keyed by the
225
+ // canonical handle. Handle reachable (JS holds the wrapper, or C owns the object
226
+ // and the toggle ref roots the handle) → callback reachable → a connected handler
227
+ // still fires. Handle reachable ONLY through the callback it retains → key and
228
+ // value die together, which is exactly the cycle that used to leak.
229
+ //
230
+ // Pruned on disconnect. A handler disconnected by a route that bypasses
231
+ // `disconnectSignal` (the introspected GObject.signal_handler_disconnect) leaves
232
+ // its entry behind until the handle itself goes — harmless, the closure is
233
+ // already gone, and the same divergence the gi.js handler-id registry documents.
234
+ const retainedClosures = new WeakMap();
235
+
236
+ /**
237
+ * Keep `fn` alive for as long as `handle` is alive. INTERNAL — the JS half of the
238
+ * weak closure refs in src/signals.cc.
239
+ * @param {object} handle canonical GObject handle (the WeakMap key / lifetime)
240
+ * @param {number | symbol} key handler id, or a unique key for a non-signal closure
241
+ * @param {Function} fn the callback the native closure holds weakly
242
+ */
243
+ export function retainClosure(handle, key, fn) {
244
+ let byKey = retainedClosures.get(handle);
245
+ if (byKey === undefined) {
246
+ byKey = new Map();
247
+ retainedClosures.set(handle, byKey);
248
+ }
249
+ byKey.set(key, fn);
250
+ }
251
+
252
+ /**
253
+ * Drop a retained closure (on disconnect). INTERNAL — see retainClosure.
254
+ * @param {object} handle canonical GObject handle
255
+ * @param {number | symbol} key the key it was retained under
256
+ */
257
+ export function releaseClosure(handle, key) {
258
+ retainedClosures.get(handle)?.delete(key);
259
+ }
260
+
208
261
  export let connectSignal;
209
262
  export let emitSignal;
210
263
  export let disconnectSignal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.64.2",
3
+ "version": "0.64.5",
4
4
  "description": "GObject-Introspection runtime for Node.js — load gi:// namespaces (GLib/GObject/Gio/…) with GJS-compatible semantics. Vendored + rewritten from node-gtk under MIT.",
5
5
  "type": "module",
6
6
  "license": "MIT",
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/signals.cc CHANGED
@@ -7,22 +7,36 @@ namespace nodegi {
7
7
 
8
8
  // ---- signals (milestone 1) ----
9
9
  //
10
- // A GClosure that wraps a JS callback. The callback is held by a strong
11
- // napi_ref; the closure's finalize notifier drops it. The generic marshal
12
- // converts the signal's GValue params to JS — the EMITTER instance (param 0)
13
- // first, then the signal's own params (1..n), matching GJS — and the JS return
14
- // into the signal return GValue.
10
+ // A GClosure that wraps a JS callback. The closure's finalize notifier drops the
11
+ // napi_ref. The generic marshal converts the signal's GValue params to JS — the
12
+ // EMITTER instance (param 0) first, then the signal's own params (1..n), matching
13
+ // GJS — and the JS return into the signal return GValue.
15
14
  //
16
- // Narrowed-leak semantics (with the toggle-ref bridge): a handler that does NOT
17
- // close over its own object is now collectable once C is the sole owner
18
- // (toggle-down → weak → GC). A handler that DOES close over its object forms a
19
- // GObject -> GClosure -> napi_ref -> callback -> wrapper -> handle cycle the GC
20
- // cannot break across C, so it keeps the object alive until disconnect — which is
21
- // the GJS-faithful contract (a connected self-referential handler is a reason to
22
- // stay alive). disconnect drops the closure's napi_ref (JsClosureFinalize),
23
- // breaking the cycle so the next GC collects the object. The callback ref stays
24
- // STRONG while connected (a connected handler must fire). Verified by the
25
- // signal-cycle case in test/gc-identity.test.mjs.
15
+ // REF STRENGTH IS THE WHOLE CONTRACT, and it differs per closure KIND, exactly as
16
+ // in GJS (refs/gjs/gi/closure.h: `create_marshaled` roots, `create_for_signal`
17
+ // does not):
18
+ //
19
+ // * GENERIC closure (a JS function marshalled as a GClosure IN-arg, invoked by
20
+ // arbitrary C) — STRONG. Nothing on the JS side owns it; the ref IS the owner.
21
+ // * SIGNAL closure (`.connect()`) and TEMPLATE-CALLBACK closure — WEAK. A
22
+ // handler that captures its own emitter otherwise forms a
23
+ // GObject → GClosure → napi_ref → callback → wrapper → toggle ref → GObject
24
+ // cycle that spans C, and no GC can break it: `obj.connect('x', () => obj.y)`
25
+ // made `obj` immortal. GJS has the identical cycle on paper and collects it,
26
+ // because its signal closures keep the callable UNROOTED and trace it from
27
+ // the owning wrapper (ObjectInstance::m_closures / trace_impl), which puts
28
+ // the whole cycle inside one heap the GC can reason about.
29
+ //
30
+ // node-gi has no tracing hook, so the equivalent edge is built in JS: index.js
31
+ // retains the callback in a WeakMap keyed by the canonical handle — the only
32
+ // ephemeron V8 offers over a non-extensible External, and the same shape
33
+ // `associate_closure` has. The handle is reachable exactly when GJS would trace
34
+ // the closure (JS holds the wrapper, or C owns the object and toggle.cc roots
35
+ // it), so a connected handler still fires for as long as anything can emit to it,
36
+ // and the self-referential cycle collects. A collected callback leaves the weak
37
+ // ref empty and the marshal returns early (it already tolerates a null value).
38
+ // Measured against gjs 1.88.1 with a FinalizationRegistry witness; verified by the
39
+ // signal-cycle cases in test/gc-identity.test.mjs.
26
40
 
27
41
  struct JsClosureData {
28
42
  napi_env env;
@@ -191,8 +205,11 @@ static void JsGenericClosureMarshal(GClosure* closure, GValue* return_value, gui
191
205
 
192
206
  // JS function → a floating marshaled GClosure (the engine's twin of GJS's
193
207
  // Gjs::Closure::create_marshaled for "boxed" closure args — refs/gjs/gi/
194
- // arg-cache.cpp GClosureInTransferNone::in). The closure holds a strong ref to
195
- // the function; JsClosureFinalize drops it when the closure is invalidated /
208
+ // arg-cache.cpp GClosureInTransferNone::in). The closure holds a STRONG ref to
209
+ // the function — deliberately, and unlike the signal/template closures above:
210
+ // `create_marshaled` defaults `root = true` (refs/gjs/gi/closure.h), because no
211
+ // JS-side owner exists for a closure handed to arbitrary C. JsClosureFinalize
212
+ // drops it when the closure is invalidated /
196
213
  // finalized (the callee released its last ref, or the invoke-site released the
197
214
  // only ref because the callee never kept one). Ref/sink + release policy lives at
198
215
  // the marshalling site (marshal.cc / calls.cc CreatedClosures).
@@ -231,9 +248,11 @@ Napi::Value ConnectSignal(const Napi::CallbackInfo& info) {
231
248
  return env.Null();
232
249
  }
233
250
 
251
+ // WEAK (refcount 0) — the keep-alive is the JS-side handle→callback edge
252
+ // index.js installs; see the ref-strength contract at the top of this file.
234
253
  JsClosureData* jc = g_new0(JsClosureData, 1);
235
254
  jc->env = env;
236
- napi_create_reference(env, info[2], 1, &jc->callback);
255
+ napi_create_reference(env, info[2], 0, &jc->callback);
237
256
 
238
257
  GClosure* closure = g_closure_new_simple(sizeof(GClosure), jc);
239
258
  g_closure_set_marshal(closure, JsClosureMarshal);
@@ -468,13 +487,17 @@ static GClosure* NodeGiScopeCreateClosure(void* selfScope, void* builder,
468
487
  }
469
488
 
470
489
  // Wrap the resolved (instance-bound, arg-wrapping) JS function in a JsClosure —
471
- // the same closure type a `.connect()` produces. The closure owns a strong ref to
472
- // the function; g_object_watch_closure ties its lifetime to the widget (the
473
- // handler `this`) and invalidates it when the widget is finalized, exactly like
490
+ // the same closure type a `.connect()` produces, and WEAK for the same reason:
491
+ // the resolver's dispatcher captures the widget handle, so a strong ref made
492
+ // every templated widget immortal. gi.js's resolveTemplateCallback retains the
493
+ // dispatcher on the widget handle before returning it, which is what GJS's Gtk
494
+ // override does with `Gi.associateClosure(lifetimeObject, …)`
495
+ // (refs/gjs/modules/core/overrides/Gtk.js). g_object_watch_closure additionally
496
+ // ties the closure to the widget and invalidates it on finalize, exactly like
474
497
  // GtkBuilderCScope's own create_closure.
475
498
  JsClosureData* jc = g_new0(JsClosureData, 1);
476
499
  jc->env = rawEnv;
477
- napi_create_reference(rawEnv, resolved, 1, &jc->callback);
500
+ napi_create_reference(rawEnv, resolved, 0, &jc->callback);
478
501
  GClosure* closure = g_closure_new_simple(sizeof(GClosure), jc);
479
502
  g_closure_set_marshal(closure, JsClosureMarshal);
480
503
  g_closure_add_finalize_notifier(closure, jc, JsClosureFinalize);