@gjsify/node-gi 0.64.11 → 0.64.13

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/gettext.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // @gjsify/node-gi/gettext — types for the GJS `Gettext` module on Node.
3
3
 
4
+ /**
5
+ * Re-derive the process's message languages from the platform. A no-op off Android; on Android
6
+ * call it after the user switches the app locale. Returns the `LANGUAGE` value set, or null.
7
+ */
8
+ export function refreshLocale(): string | null;
4
9
  /** Look up `msgid` in the default domain. */
5
10
  export function gettext(msgid: string): string;
6
11
  /** Look up `msgid` in `domain`. */
@@ -63,3 +68,6 @@ export interface GettextModule {
63
68
 
64
69
  declare const Gettext: GettextModule;
65
70
  export default Gettext;
71
+
72
+ /** Re-derive message languages from the platform (Android); null elsewhere. */
73
+ export function refreshLocale(): string | null;
package/gettext.js CHANGED
@@ -25,6 +25,14 @@
25
25
  import * as native from './index.js';
26
26
  import { ensureNative } from './index.js';
27
27
  import { requireGi } from './gi.js';
28
+ import { refreshLocale } from '#host';
29
+
30
+ /**
31
+ * Re-derive the process's message languages from the platform. A no-op off Android; on
32
+ * Android call it after the user switches the app locale (LANGUAGE is re-read per lookup).
33
+ * @returns {string | null}
34
+ */
35
+ export { refreshLocale };
28
36
 
29
37
  // GLib is resolved on first lookup, not at import: loading a namespace walks the
30
38
  // typelib, and a module imported for its LocaleCategory alone should not pay for
package/gi.js CHANGED
@@ -1190,8 +1190,11 @@ function runJsPropertySetter(handle, propertyName) {
1190
1190
  const up = instance[USER_PROTO];
1191
1191
  if (up === undefined) return;
1192
1192
  const set = findPropertySetter(up, propertyName);
1193
- if (set === undefined) return; // no JS accessor: the store is the single backing store
1193
+ // No JS accessor: the store is the single backing store. The `false` tells the engine
1194
+ // so, and it stops crossing for this (class, property) — see NodeGiEnvData::noJsSetter.
1195
+ if (set === undefined) return false;
1194
1196
  set.call(instance, wrapReturn(native.getProperty(handle, propertyName)));
1197
+ return true;
1195
1198
  }
1196
1199
  // Guarded because the addon paired with this JS may PREDATE it, and that pairing is
1197
1200
  // deliberate rather than hypothetical: gtk-os-suites.yml's shipped-closure legs run
@@ -1704,6 +1707,15 @@ function wrapInstance(handle, userProto) {
1704
1707
  // Built fresh, every `obj.m(…)` in a loop allocated a closure and re-asked for
1705
1708
  // the type name across the N-API boundary. Null prototype: `constructor` and
1706
1709
  // friends must not read as a cached thunk.
1710
+ // Positive answers only: a GObject class never loses a property, so a hit is final
1711
+ // and saves one boundary crossing per get/set; a miss stays a live lookup.
1712
+ const knownProps = new Set();
1713
+ const hasProp = (name) => {
1714
+ if (knownProps.has(name)) return true;
1715
+ if (!native.hasProperty(handle, name)) return false;
1716
+ knownProps.add(name);
1717
+ return true;
1718
+ };
1707
1719
  const giMethodThunks = Object.create(null);
1708
1720
  const giMethodThunk = (prop) => {
1709
1721
  let thunk = giMethodThunks[prop];
@@ -1815,7 +1827,7 @@ function wrapInstance(handle, userProto) {
1815
1827
  const shim = objectPrototypeShim(prop);
1816
1828
  if (shim !== undefined) return (...args) => shim(handle, args);
1817
1829
  const propName = toKebab(prop);
1818
- if (native.hasProperty(handle, propName)) {
1830
+ if (hasProp(propName)) {
1819
1831
  // A READ materialises a custom property's value, exactly as GJS's generated
1820
1832
  // getter writes the default into its private field on first look-up — which
1821
1833
  // is what makes a LATER assignment of that same default silent. See the
@@ -1864,7 +1876,7 @@ function wrapInstance(handle, userProto) {
1864
1876
  }
1865
1877
  }
1866
1878
  const propName = toKebab(prop);
1867
- if (native.hasProperty(handle, propName)) {
1879
+ if (hasProp(propName)) {
1868
1880
  // Equal-value guard, GJS's `_generateAccessors` setter verbatim:
1869
1881
  // `if (value !== this[privateName]) { …; this.notify(name) }`
1870
1882
  // (refs/gjs/modules/core/_common.js). GJS puts it in the accessor it
@@ -1886,6 +1898,12 @@ function wrapInstance(handle, userProto) {
1886
1898
  // all three spellings onto it.
1887
1899
  const custom = up !== undefined && customPropertiesByProto.get(up)?.has(propName) === true;
1888
1900
  if (custom && materialise(propName)) {
1901
+ if (typeof native.setPropertyIfChanged === 'function') {
1902
+ // One crossing: the engine compares and stores. 2 = a value type it
1903
+ // does not decide (objects, enums…), so take the two-call path.
1904
+ const done = native.setPropertyIfChanged(handle, propName, unwrapArg(value));
1905
+ if (done !== 2) return true;
1906
+ }
1889
1907
  if (wrapReturn(native.getProperty(handle, propName)) === value) return true;
1890
1908
  }
1891
1909
  native.setProperty(handle, propName, unwrapArg(value));
@@ -1903,7 +1921,7 @@ function wrapInstance(handle, userProto) {
1903
1921
  // GI methods are resolved dynamically and are intentionally not reported.)
1904
1922
  const up = t[USER_PROTO];
1905
1923
  if (up !== undefined && findProtoDescriptor(up, prop) !== undefined) return true;
1906
- if (native.hasProperty(handle, toKebab(prop))) return true;
1924
+ if (hasProp(toKebab(prop))) return true;
1907
1925
  }
1908
1926
  return false;
1909
1927
  },
package/gtk-runtime.d.ts CHANGED
@@ -13,8 +13,6 @@ export function maybeReexecForGtkRuntime(): void;
13
13
  export function maybePrependGtkRuntimeDllPath(): void;
14
14
  /** Windows: wire the env for a full-windowing bundle's runtime data — GSettings schemas, gdk-pixbuf loaders, icon themes, fontconfig (no-op off win32 / for a display-free bundle). */
15
15
  export function maybeWireGtkWindowingEnv(): void;
16
- /** darwin: point libgda at the bundle's SQLite provider via `GDA_TOP_BUILD_DIR` (only when unset; no-op for a bundle without `lib/libgda-6.0/providers`). */
17
- export function wireGdaProviders(bundle: { dir: string }): void;
18
16
  /** Activate the bundled GTK runtime for the native engine, if one is present. */
19
17
  export function activateBundledGtkRuntime(native: {
20
18
  prependSearchPath: (p: string) => void;
package/gtk-runtime.js CHANGED
@@ -641,26 +641,6 @@ export function maybeWireGtkWindowingEnv() {
641
641
  if (existsSync(gioModules)) setIfUnset('GIO_MODULE_DIR', gioModules);
642
642
  }
643
643
 
644
- /**
645
- * Point libgda at the bundle's database providers (darwin; `@gjsify/sqlite` / `node:sqlite`).
646
- *
647
- * libgda loads a provider as a GModule from a directory COMPILED IN as the build prefix —
648
- * `<keg>/lib/libgda-6.0/providers` — and, with that keg absent, `Gda.Connection.new_from_string
649
- * ('SQLite', …)` throws `No provider 'SQLite' installed`. With the keg PRESENT it is worse: brew's
650
- * provider loads against brew's libgda and glib beside the bundle's own. The one override libgda
651
- * has is `GDA_TOP_BUILD_DIR`, read as `<dir>/providers` at the first provider scan, which is long
652
- * after this runs. In the JS and not in a launcher for the reason `GJSIFY_GI_LIBRARY_PATH` is:
653
- * a signed app loses `DYLD_*`, never `process.env`.
654
- * Set only when unset, so an operator pointing libgda elsewhere wins; a no-op for a bundle
655
- * that carries no provider dir.
656
- * @param {{ dir: string }} bundle
657
- */
658
- export function wireGdaProviders(bundle) {
659
- const gdaDir = join(bundle.dir, 'lib', 'libgda-6.0');
660
- if (!existsSync(join(gdaDir, 'providers'))) return;
661
- if (!process.env.GDA_TOP_BUILD_DIR) process.env.GDA_TOP_BUILD_DIR = gdaDir;
662
- }
663
-
664
644
  /** The plugin scanner's leaf name — the bundles ship the same tree on both OSes. */
665
645
  function gstScannerLeaf() {
666
646
  return process.platform === 'win32' ? 'gst-plugin-scanner.exe' : 'gst-plugin-scanner';
@@ -826,7 +806,6 @@ export function activateBundledGtkRuntime(native) {
826
806
  // variable REPLACES rather than extends.
827
807
  process.env.DYLD_FALLBACK_LIBRARY_PATH = composeDyldFallback([bundle.libDir]);
828
808
  }
829
- wireGdaProviders(bundle);
830
809
  }
831
810
 
832
811
  activated = bundle;
@@ -116,9 +116,130 @@ function deriveAppDir() {
116
116
  }
117
117
  }
118
118
 
119
+ // A Vite-built app exposes only the `android.*` packages its own code touched, and nothing
120
+ // touches `android.system` — so `android.system.Os` is undefined there and every setenv
121
+ // silently did nothing (LANGUAGE and XDG_* never reached the native process). Reflection
122
+ // reaches the class whatever the bundle referenced.
123
+ function androidSetenv(name, value) {
124
+ const os = globalThis.android?.system?.Os;
125
+ if (os) {
126
+ os.setenv(name, value, true);
127
+ return;
128
+ }
129
+ const java = globalThis.java;
130
+ if (!java?.lang?.Class) return;
131
+ try {
132
+ const types = java.lang.reflect.Array.newInstance(java.lang.Class.class, 3);
133
+ java.lang.reflect.Array.set(types, 0, java.lang.String.class);
134
+ java.lang.reflect.Array.set(types, 1, java.lang.String.class);
135
+ java.lang.reflect.Array.set(types, 2, java.lang.Boolean.TYPE);
136
+ const args = java.lang.reflect.Array.newInstance(java.lang.Object.class, 3);
137
+ java.lang.reflect.Array.set(args, 0, name);
138
+ java.lang.reflect.Array.set(args, 1, value);
139
+ java.lang.reflect.Array.set(args, 2, java.lang.Boolean.TRUE);
140
+ java.lang.Class.forName('android.system.Os').getMethod('setenv', types).invoke(null, args);
141
+ } catch (e) {
142
+ globalThis.console?.warn?.(`node-gi: setenv(${name}) failed: ${e}`);
143
+ }
144
+ }
145
+
119
146
  function setNativeEnv(name, value) {
120
147
  env[name] = value;
121
- globalThis.android?.system?.Os?.setenv(name, value, true);
148
+ androidSetenv(name, value);
149
+ }
150
+
151
+ /**
152
+ * The LANGUAGE value for a list of BCP-47 tags: `de-DE` → `de_DE:de`, `zh-Hans-CN` → `zh_CN:zh`,
153
+ * de-duplicated, cut after the first English tag, with `C` last as GLib orders it. Bionic's setlocale accepts only C/C.UTF-8
154
+ * and Android sets no LANG, so GLib's language list (and the libintl in gi-runtime-android,
155
+ * which reads LANGUAGE on every lookup) has nothing else to go on. Pure — no device needed.
156
+ * @param {string[]} tags
157
+ * @returns {string}
158
+ */
159
+ export function languageFromBcp47(tags) {
160
+ const out = [];
161
+ const add = (name) => {
162
+ if (!out.includes(name)) out.push(name);
163
+ };
164
+ for (const tag of tags) {
165
+ const parts = String(tag).split(/[-_]/).filter(Boolean);
166
+ if (parts.length === 0 || !/^[A-Za-z]{2,3}$/.test(parts[0])) continue;
167
+ const lang = parts[0].toLowerCase();
168
+ const region = parts.slice(1).find((p) => /^([A-Za-z]{2}|\d{3})$/.test(p));
169
+ if (region) add(`${lang}_${region.toUpperCase()}`);
170
+ add(lang);
171
+ // English is the msgid language, so no catalog exists for it and gettext would go on to
172
+ // the NEXT entry: with the app switched to en-US over a German system the list is
173
+ // [en-US, de-DE], and every string came back German. Nothing after English may apply.
174
+ if (lang === 'en') break;
175
+ }
176
+ add('C');
177
+ return out.join(':');
178
+ }
179
+
180
+ function platformLanguageTags() {
181
+ const locales = globalThis.android?.content?.res?.Resources?.getSystem?.()?.getConfiguration?.()?.getLocales?.();
182
+ const tags = [];
183
+ if (locales) {
184
+ for (let i = 0; i < locales.size(); i++) tags.push(String(locales.get(i).toLanguageTag()));
185
+ }
186
+ if (tags.length === 0) {
187
+ const one = globalThis.java?.util?.Locale?.getDefault?.();
188
+ if (one) tags.push(String(one.toLanguageTag()));
189
+ }
190
+ return tags;
191
+ }
192
+
193
+ /**
194
+ * Point LANGUAGE at the platform's current locale list. Call it again when the user
195
+ * switches the app's language: libintl and GLib re-read the variable, nothing is cached.
196
+ * The explicit __NODE_GI_LANGUAGE global wins, for a host that manages its own.
197
+ * @returns {string | null} the value set, or null when the platform gave nothing
198
+ */
199
+ export function refreshLocale() {
200
+ let value = globalThis.__NODE_GI_LANGUAGE;
201
+ if (typeof value !== 'string' || value === '') {
202
+ try {
203
+ const tags = platformLanguageTags();
204
+ value = tags.length > 0 ? languageFromBcp47(tags) : null;
205
+ } catch {
206
+ value = null;
207
+ }
208
+ }
209
+ if (value === null || value === undefined) return null;
210
+ setNativeEnv('LANGUAGE', value);
211
+ return value;
212
+ }
213
+
214
+ let followingSystemLocale = false;
215
+
216
+ /**
217
+ * Re-run `refreshLocale()` whenever the system configuration changes — a language switch is
218
+ * one. The Application-level ComponentCallbacks2 fires whether or not the activity declares
219
+ * `locale` in `configChanges`. Registered once, from the host that owns the LANGUAGE bridge,
220
+ * so no consumer has to know the variable exists.
221
+ * @returns {boolean} whether a callback is registered
222
+ */
223
+ export function followSystemLocale() {
224
+ if (followingSystemLocale) return true;
225
+ try {
226
+ const app = globalThis.com?.tns?.NativeScriptApplication?.getInstance();
227
+ const ComponentCallbacks2 = globalThis.android?.content?.ComponentCallbacks2;
228
+ if (!app || !ComponentCallbacks2) return false;
229
+ app.registerComponentCallbacks(
230
+ new ComponentCallbacks2({
231
+ onConfigurationChanged: () => {
232
+ refreshLocale();
233
+ },
234
+ onLowMemory: () => {},
235
+ onTrimMemory: () => {},
236
+ }),
237
+ );
238
+ followingSystemLocale = true;
239
+ } catch {
240
+ // A callback missing only costs the live switch; the locale at start is still set.
241
+ }
242
+ return followingSystemLocale;
122
243
  }
123
244
 
124
245
  function appDirectory() {
@@ -165,6 +286,8 @@ function ensureJavaVm(native) {
165
286
  }
166
287
 
167
288
  function configureEnvironment() {
289
+ refreshLocale();
290
+ followSystemLocale();
168
291
  const systemDir = globalThis.__NODE_GI_SYSTEM_DIR;
169
292
  if (typeof systemDir === 'string' && systemDir !== '') {
170
293
  setNativeEnv('XDG_DATA_DIRS', `${systemDir}/share`);
package/host.node.js CHANGED
@@ -32,7 +32,21 @@ import { setImmediate, setInterval, clearInterval } from 'node:timers';
32
32
  // loop-liveness hooks in gi.js would silently never fire.
33
33
  import runtimeProcess from 'node:process';
34
34
 
35
- export { setImmediate, setInterval, clearInterval, runtimeProcess, windowingEnvWrites, bundledLocaleDir };
35
+ // Only Android derives the locale from the platform (host.nativescript.js); a desktop
36
+ // process already has LANG, so there is nothing to refresh.
37
+ function refreshLocale() {
38
+ return null;
39
+ }
40
+
41
+ export {
42
+ setImmediate,
43
+ setInterval,
44
+ clearInterval,
45
+ runtimeProcess,
46
+ windowingEnvWrites,
47
+ bundledLocaleDir,
48
+ refreshLocale,
49
+ };
36
50
 
37
51
  const require = createRequire(import.meta.url);
38
52
  const here = packageRoot;
package/index.d.ts CHANGED
@@ -307,6 +307,13 @@ export function getProperty(handle: GObjectHandle, name: string): unknown;
307
307
 
308
308
  /** Write a GObject property. */
309
309
  export function setProperty(handle: GObjectHandle, name: string, value: unknown): void;
310
+ /**
311
+ * `setProperty` with gi.js's equal-value guard folded in, so a guarded write is one crossing.
312
+ * Returns 0 when the value was stored, 1 when it equals the current one (nothing stored, no
313
+ * notify), 2 when the property's type is not decided here (objects, enums, …; nothing done).
314
+ * Absent from an addon older than this JS.
315
+ */
316
+ export function setPropertyIfChanged(handle: GObjectHandle, name: string, value: unknown): 0 | 1 | 2;
310
317
 
311
318
  /**
312
319
  * Whether the instance's type has a GObject property by this name (kebab- or
@@ -632,6 +639,7 @@ declare const native: {
632
639
  getTemplateChild: typeof getTemplateChild;
633
640
  getProperty: typeof getProperty;
634
641
  setProperty: typeof setProperty;
642
+ setPropertyIfChanged?: typeof setPropertyIfChanged;
635
643
  hasProperty: typeof hasProperty;
636
644
  getTypeName: typeof getTypeName;
637
645
  classInfoForTypeName: typeof classInfoForTypeName;
package/index.js CHANGED
@@ -67,6 +67,7 @@ export function ensureNative() {
67
67
  getTemplateChild = addon.getTemplateChild;
68
68
  getProperty = addon.getProperty;
69
69
  setProperty = addon.setProperty;
70
+ setPropertyIfChanged = addon.setPropertyIfChanged;
70
71
  hasProperty = addon.hasProperty;
71
72
  getTypeName = addon.getTypeName;
72
73
  classInfoForTypeName = addon.classInfoForTypeName;
@@ -177,6 +178,7 @@ export let callClassVfunc;
177
178
  export let getTemplateChild;
178
179
  export let getProperty;
179
180
  export let setProperty;
181
+ export let setPropertyIfChanged;
180
182
  export let hasProperty;
181
183
  export let getTypeName;
182
184
  export let classInfoForTypeName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.64.11",
3
+ "version": "0.64.13",
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",
@@ -10,6 +10,7 @@
10
10
  "imports": {
11
11
  "#host": {
12
12
  "nativescript": "./host.nativescript.js",
13
+ "wasm": "./host.wasm.js",
13
14
  "default": "./host.node.js"
14
15
  }
15
16
  },
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/addon.cc CHANGED
@@ -109,6 +109,7 @@ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
109
109
  exports.Set("getTemplateChild", Napi::Function::New(env, GetTemplateChild));
110
110
  exports.Set("getProperty", Napi::Function::New(env, GetProperty));
111
111
  exports.Set("setProperty", Napi::Function::New(env, SetProperty));
112
+ exports.Set("setPropertyIfChanged", Napi::Function::New(env, SetPropertyIfChanged));
112
113
  exports.Set("hasProperty", Napi::Function::New(env, HasProperty));
113
114
  exports.Set("getTypeName", Napi::Function::New(env, GetTypeName));
114
115
  exports.Set("classInfoForTypeName", Napi::Function::New(env, ClassInfoForTypeName));
package/src/calls.cc CHANGED
@@ -250,7 +250,7 @@ static void NodeGiCallbackTrampoline(ffi_cif* /*cif*/, void* result, void** args
250
250
  // napi_make_callback drains nextTick/microtasks around the call (Node; on
251
251
  // Bun/Deno the checkpoint is run by NodeGiMaybeDrainMicrotasks below).
252
252
  g_loopDispatchDepth++;
253
- napi_status st = napi_make_callback(env, nullptr, global, fn, jsArgs.size(), jsArgs.data(), &ret);
253
+ napi_status st = NodeGiCallIntoJs(env, global, fn, jsArgs.size(), jsArgs.data(), &ret);
254
254
  g_loopDispatchDepth--;
255
255
  if (st != napi_ok) ret = nullptr;
256
256
  }
package/src/class.cc CHANGED
@@ -264,7 +264,7 @@ static void NodeGiVFuncTrampoline(ffi_cif* /*cif*/, void* result, void** args,
264
264
  // wrapped instance is the receiver (`this`).
265
265
  g_loopDispatchDepth++;
266
266
  napi_status st =
267
- napi_make_callback(env, nullptr, recv, fn, jsArgs.size(), jsArgs.data(), &ret);
267
+ NodeGiCallIntoJs(env, recv, fn, jsArgs.size(), jsArgs.data(), &ret);
268
268
  g_loopDispatchDepth--;
269
269
  if (st != napi_ok) ret = nullptr;
270
270
  }
@@ -387,6 +387,10 @@ static void MaybeRunJsPropertySetter(NodeGiClassData* cd, GObject* obj, const ch
387
387
  // Never enter JS during GC / env teardown (same gate as the vfunc trampoline and
388
388
  // RunJsConstructorForCObject); the property is already stored either way.
389
389
  if (!NodeGiJsAvailable(env)) return;
390
+ NodeGiEnvData* d = EnvData(env);
391
+ GParamSpec* pspec = g_object_class_find_property(G_OBJECT_GET_CLASS(obj), name);
392
+ const std::pair<GType, GParamSpec*> memoKey(G_OBJECT_TYPE(obj), pspec);
393
+ if (d != nullptr && pspec != nullptr && d->noJsSetter.count(memoKey) != 0) return;
390
394
  Napi::Env napiEnv(env);
391
395
  Napi::HandleScope scope(napiEnv);
392
396
  NodeGiPumpJsDispatchScope pumpWindow;
@@ -394,7 +398,6 @@ static void MaybeRunJsPropertySetter(NodeGiClassData* cd, GObject* obj, const ch
394
398
  Napi::Value handle = PeekGObjectHandle(napiEnv, obj);
395
399
  if (handle.IsEmpty()) return; // construction has not reached the wrap yet
396
400
 
397
- NodeGiEnvData* d = EnvData(env);
398
401
  napi_value cb = nullptr;
399
402
  if (d == nullptr || d->propertySetCallback == nullptr ||
400
403
  napi_get_reference_value(env, d->propertySetCallback, &cb) != napi_ok || cb == nullptr) {
@@ -410,7 +413,19 @@ static void MaybeRunJsPropertySetter(NodeGiClassData* cd, GObject* obj, const ch
410
413
  // own frame and must not get a microtask checkpoint in the middle of it — the same
411
414
  // reasoning as RunJsConstructorForCObject, and gjs's JS_SetProperty takes no
412
415
  // checkpoint either.
413
- if (napi_call_function(env, undef, cb, 2, args, &ret) != napi_ok) {
416
+ napi_status callStatus = napi_call_function(env, undef, cb, 2, args, &ret);
417
+ if (callStatus == napi_ok && pspec != nullptr && ret != nullptr) {
418
+ // gi.js answers a literal `false` only once the instance's prototype chain has been
419
+ // searched and holds no accessor; any other answer (no wrapper yet, a setter ran)
420
+ // keeps the crossing.
421
+ bool isBool = false;
422
+ napi_valuetype vt = napi_undefined;
423
+ if (napi_typeof(env, ret, &vt) == napi_ok && vt == napi_boolean &&
424
+ napi_get_value_bool(env, ret, &isBool) == napi_ok && !isBool) {
425
+ d->noJsSetter.insert(memoKey);
426
+ }
427
+ }
428
+ if (callStatus != napi_ok) {
414
429
  // The setter threw. A pending JS exception must never cross back into GObject
415
430
  // (g_object_set has no way to report one), so fold it into a g_warning — gjs logs
416
431
  // the uncaught exception at the same point (gjs_log_exception_uncaught).
package/src/common.h CHANGED
@@ -53,6 +53,8 @@
53
53
  #include <map>
54
54
  #include <memory>
55
55
  #include <mutex>
56
+ #include <set>
57
+ #include <unordered_map>
56
58
  #include <string>
57
59
  #include <thread>
58
60
  #include <vector>
@@ -114,6 +116,17 @@ struct NodeGiEnvData {
114
116
  // setter from its set_property vfunc (refs/gjs/gi/gobject.cpp jsobj_set_gproperty).
115
117
  // Per-env for the same reason as errorBuilder (a napi_ref is env-specific).
116
118
  napi_ref propertySetCallback = nullptr;
119
+ // (instance GType, pspec) pairs for which gi.js answered "this class chain declares
120
+ // no JS accessor" — the store is then the whole write and NodeGiSetProperty skips the
121
+ // JS crossing. Keyed by the INSTANCE's GType because a subclass may add an accessor
122
+ // its parent lacks; mirrors gi.js's per-prototype memo, so the answer is as final as
123
+ // it is there.
124
+ std::set<std::pair<GType, GParamSpec*>> noJsSetter;
125
+ // One JS External per GParamSpec handed out by GValueToJs (a `notify` emission passes
126
+ // the pspec every time). Minting a finalizable External per emission is the dominant
127
+ // cost of a notify on Wasm; the ref (count 1) keeps the External, which keeps the
128
+ // pspec ref, until env teardown.
129
+ std::unordered_map<GParamSpec*, napi_ref> paramSpecHandles;
117
130
  };
118
131
 
119
132
  void NodeGiEnvDataFinalize(napi_env env, void* data, void* hint);
@@ -702,6 +715,27 @@ extern int g_syncEmitDepth;
702
715
  // InternalCallbackScopes and GJS's "drain when the last JS frame exits" do.
703
716
  extern int g_loopDispatchDepth;
704
717
 
718
+ // Enter JS at a loop-dispatched callback boundary (signals.cc / calls.cc /
719
+ // class.cc / loop.cc / private.cc). On a real Node-API host this is
720
+ // napi_make_callback, which runs the nextTick + microtask checkpoint at the
721
+ // boundary. Under Emscripten the Node-API host is emnapi, whose
722
+ // napi_make_callback is a thin forward to @emnapi/node-binding and returns
723
+ // napi_generic_failure whenever that binding is absent — which it always is
724
+ // outside Node, i.e. in every browser. The call then fails SILENTLY: the GI
725
+ // callback returns FALSE, the GSource is removed, and no handler ever runs.
726
+ // napi_call_function is the portable entry; the checkpoint it skips is the one
727
+ // NodeGiMaybeDrainMicrotasks already performs for Bun/Deno, and the wasm host
728
+ // registers the same drain (host.wasm.js).
729
+ static inline napi_status NodeGiCallIntoJs(napi_env env, napi_value recv, napi_value fn,
730
+ size_t argc, const napi_value* argv,
731
+ napi_value* result) {
732
+ #ifdef __EMSCRIPTEN__
733
+ return napi_call_function(env, recv, fn, argc, argv, result);
734
+ #else
735
+ return napi_make_callback(env, nullptr, recv, fn, argc, argv, result);
736
+ #endif
737
+ }
738
+
705
739
  // Cross-runtime microtask checkpoint (Bun/Deno — see loop.cc). Invokes the
706
740
  // runtime-native drain registered via setMicrotaskDrain after an outermost
707
741
  // loop-dispatched callback returns; a no-op on Node (never registered), with a
@@ -883,6 +917,7 @@ Napi::Value IsFundamentalHandle(const Napi::CallbackInfo& info);
883
917
  Napi::Value NewObject(const Napi::CallbackInfo& info);
884
918
  Napi::Value GetProperty(const Napi::CallbackInfo& info);
885
919
  Napi::Value SetProperty(const Napi::CallbackInfo& info);
920
+ Napi::Value SetPropertyIfChanged(const Napi::CallbackInfo& info);
886
921
  Napi::Value HasProperty(const Napi::CallbackInfo& info);
887
922
  Napi::Value GetTypeName(const Napi::CallbackInfo& info);
888
923
  Napi::Value ClassInfoForTypeName(const Napi::CallbackInfo& info);
package/src/loop.cc CHANGED
@@ -107,7 +107,7 @@ static void DrainMicrotasks() {
107
107
  if (napi_get_reference_value(env, g_process_ref, &process_v) == napi_ok &&
108
108
  napi_get_reference_value(env, g_tick_callback_ref, &tick) == napi_ok &&
109
109
  process_v != nullptr && tick != nullptr) {
110
- napi_make_callback(env, nullptr, process_v, tick, 0, nullptr, &result);
110
+ NodeGiCallIntoJs(env, process_v, tick, 0, nullptr, &result);
111
111
  }
112
112
  napi_close_handle_scope(env, scope);
113
113
  }
package/src/object.cc CHANGED
@@ -304,7 +304,24 @@ Napi::Value GValueToJs(Napi::Env env, const GValue* v) {
304
304
  // .name/.get_name()/.value_type/.nick/.blurb/.flags/.owner_type — matching
305
305
  // gjs. g_value_get_param BORROWS (transfer none).
306
306
  GParamSpec* p = g_value_get_param(v);
307
- return MakeParamSpecHandle(env, p, GI_TRANSFER_NOTHING);
307
+ if (p == nullptr) return env.Null();
308
+ NodeGiEnvData* d = EnvData(env);
309
+ if (d == nullptr) return MakeParamSpecHandle(env, p, GI_TRANSFER_NOTHING);
310
+ auto hit = d->paramSpecHandles.find(p);
311
+ if (hit != d->paramSpecHandles.end()) {
312
+ napi_value cached = nullptr;
313
+ if (napi_get_reference_value(env, hit->second, &cached) == napi_ok && cached != nullptr) {
314
+ return Napi::Value(env, cached);
315
+ }
316
+ }
317
+ Napi::Value fresh = MakeParamSpecHandle(env, p, GI_TRANSFER_NOTHING);
318
+ if (!fresh.IsEmpty() && fresh.IsExternal()) {
319
+ napi_ref ref = nullptr;
320
+ if (napi_create_reference(env, fresh, 1, &ref) == napi_ok && ref != nullptr) {
321
+ d->paramSpecHandles[p] = ref;
322
+ }
323
+ }
324
+ return fresh;
308
325
  }
309
326
  default:
310
327
  Napi::TypeError::New(env, std::string("Unsupported property GType ") +
@@ -765,7 +782,11 @@ Napi::Value GetProperty(const Napi::CallbackInfo& info) {
765
782
  }
766
783
 
767
784
  // setProperty(handle, name, value) -> void
768
- Napi::Value SetProperty(const Napi::CallbackInfo& info) {
785
+ // setPropertyIfChanged(handle, name, value) -> 0 stored | 1 equal, nothing done | 2 undecided,
786
+ // nothing done. Folds gi.js's equal-value guard (`wrapReturn(getProperty) === value`) into the
787
+ // store so a guarded set is ONE crossing. Only the types whose JS form compares by value are
788
+ // decided here; anything else (objects, boxed, enums…) answers 2 and gi.js takes the two-call path.
789
+ static Napi::Value SetPropertyImpl(const Napi::CallbackInfo& info, bool guarded) {
769
790
  Napi::Env env = info.Env();
770
791
  if (info.Length() < 3 || !info[1].IsString()) {
771
792
  Napi::TypeError::New(env, "setProperty(handle, name: string, value)").ThrowAsJavaScriptException();
@@ -782,6 +803,26 @@ Napi::Value SetProperty(const Napi::CallbackInfo& info) {
782
803
  // gjs refuses an out-of-range value for these property types on assignment
783
804
  // (object.cpp, with its own text); JsToGValue below now refuses them too, with the
784
805
  // gjs_value_to_g_value text.
806
+ if (guarded) {
807
+ switch (G_TYPE_FUNDAMENTAL(pspec->value_type)) {
808
+ case G_TYPE_BOOLEAN: case G_TYPE_CHAR: case G_TYPE_UCHAR: case G_TYPE_INT:
809
+ case G_TYPE_UINT: case G_TYPE_LONG: case G_TYPE_ULONG: case G_TYPE_INT64:
810
+ case G_TYPE_UINT64: case G_TYPE_FLOAT: case G_TYPE_DOUBLE: case G_TYPE_STRING:
811
+ break;
812
+ default:
813
+ return Napi::Number::New(env, 2);
814
+ }
815
+ GValue cur = G_VALUE_INIT;
816
+ g_value_init(&cur, pspec->value_type);
817
+ g_object_get_property(obj, name.c_str(), &cur);
818
+ Napi::Value curJs = GValueToJs(env, &cur);
819
+ g_value_unset(&cur);
820
+ if (env.IsExceptionPending()) return env.Undefined();
821
+ bool same = false;
822
+ if (!curJs.IsEmpty() && napi_strict_equals(env, curJs, info[2], &same) == napi_ok && same) {
823
+ return Napi::Number::New(env, 1);
824
+ }
825
+ }
785
826
  GITypeTag intTag = GI_TYPE_TAG_VOID;
786
827
  switch (G_TYPE_FUNDAMENTAL(pspec->value_type)) {
787
828
  case G_TYPE_CHAR: intTag = GI_TYPE_TAG_INT8; break;
@@ -809,7 +850,12 @@ Napi::Value SetProperty(const Napi::CallbackInfo& info) {
809
850
  g_object_set_property(obj, name.c_str(), &v);
810
851
  }
811
852
  g_value_unset(&v);
812
- return env.Undefined();
853
+ return guarded ? Napi::Number::New(env, 0).As<Napi::Value>() : env.Undefined();
854
+ }
855
+
856
+ Napi::Value SetProperty(const Napi::CallbackInfo& info) { return SetPropertyImpl(info, false); }
857
+ Napi::Value SetPropertyIfChanged(const Napi::CallbackInfo& info) {
858
+ return SetPropertyImpl(info, true);
813
859
  }
814
860
 
815
861
  // getTypeName(handle) -> string (the runtime GType name of an instance)
package/src/private.cc CHANGED
@@ -109,7 +109,7 @@ static GLogWriterOutput NodeGiLogWriterWrapper(GLogLevelFlags log_level, const G
109
109
  napi_get_global(env, &global);
110
110
  napi_value args[2] = {Napi::Number::New(napiEnv, static_cast<double>(log_level)), obj};
111
111
  napi_value ret = nullptr;
112
- napi_status st = napi_make_callback(env, nullptr, global, fn, 2, args, &ret);
112
+ napi_status st = NodeGiCallIntoJs(env, global, fn, 2, args, &ret);
113
113
  if (st != napi_ok || napiEnv.IsExceptionPending()) {
114
114
  // A throwing writer must never wedge the logging path (nor leave a pending
115
115
  // exception across the C boundary): surface + clear, then default-write.
@@ -209,7 +209,7 @@ static gboolean NodeGiBindingTransformInvoke(GBinding* binding, const GValue* fr
209
209
  napi_value global = nullptr;
210
210
  napi_get_global(env, &global);
211
211
  napi_value ret = nullptr;
212
- napi_status st = napi_make_callback(env, nullptr, global, fn, 2, args, &ret);
212
+ napi_status st = NodeGiCallIntoJs(env, global, fn, 2, args, &ret);
213
213
  if (st != napi_ok || napiEnv.IsExceptionPending()) {
214
214
  SurfacePendingException(env, "property binding transform");
215
215
  return FALSE;
package/src/repo.cc CHANGED
@@ -19,6 +19,7 @@ void NodeGiEnvDataFinalize(napi_env env, void* data, void* /*hint*/) {
19
19
  if (d->microtaskDrain != nullptr) napi_delete_reference(env, d->microtaskDrain);
20
20
  if (d->constructCallback != nullptr) napi_delete_reference(env, d->constructCallback);
21
21
  if (d->propertySetCallback != nullptr) napi_delete_reference(env, d->propertySetCallback);
22
+ for (auto& kv : d->paramSpecHandles) napi_delete_reference(env, kv.second);
22
23
  delete d;
23
24
  }
24
25
 
package/src/signals.cc CHANGED
@@ -142,7 +142,7 @@ static void JsClosureMarshalImpl(GClosure* closure, GValue* return_value, guint
142
142
  napi_value global = nullptr;
143
143
  napi_get_global(jc->env, &global);
144
144
  g_loopDispatchDepth++;
145
- st = napi_make_callback(jc->env, nullptr, global, cbv, args.size(), args.data(), &result);
145
+ st = NodeGiCallIntoJs(jc->env, global, cbv, args.size(), args.data(), &result);
146
146
  g_loopDispatchDepth--;
147
147
  }
148
148
  if (st != napi_ok) {