@gtkx/react 1.0.0-rc.2 → 1.0.0-rc.3

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.
Files changed (67) hide show
  1. package/dist/adw/element-behaviors.js +6 -36
  2. package/dist/adw/element-behaviors.js.map +1 -1
  3. package/dist/adw/element-config.d.ts +3 -1
  4. package/dist/adw/element-config.d.ts.map +1 -1
  5. package/dist/adw/element-config.js +26 -36
  6. package/dist/adw/element-config.js.map +1 -1
  7. package/dist/components/window.d.ts.map +1 -1
  8. package/dist/components/window.js +13 -2
  9. package/dist/components/window.js.map +1 -1
  10. package/dist/config.d.ts +1 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +1 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/element-config.d.ts.map +1 -1
  15. package/dist/element-config.js +3 -0
  16. package/dist/element-config.js.map +1 -1
  17. package/dist/hooks/use-signal.d.ts +4 -0
  18. package/dist/hooks/use-signal.d.ts.map +1 -1
  19. package/dist/hooks/use-signal.js +4 -0
  20. package/dist/hooks/use-signal.js.map +1 -1
  21. package/dist/internal.d.ts +1 -0
  22. package/dist/internal.d.ts.map +1 -1
  23. package/dist/internal.js +1 -0
  24. package/dist/internal.js.map +1 -1
  25. package/dist/reconciler/apply-props.d.ts.map +1 -1
  26. package/dist/reconciler/apply-props.js +12 -6
  27. package/dist/reconciler/apply-props.js.map +1 -1
  28. package/dist/reconciler/behaviors.d.ts.map +1 -1
  29. package/dist/reconciler/behaviors.js +7 -2
  30. package/dist/reconciler/behaviors.js.map +1 -1
  31. package/dist/reconciler/host-config.d.ts.map +1 -1
  32. package/dist/reconciler/host-config.js +2 -6
  33. package/dist/reconciler/host-config.js.map +1 -1
  34. package/dist/reconciler/placement.d.ts.map +1 -1
  35. package/dist/reconciler/placement.js +24 -8
  36. package/dist/reconciler/placement.js.map +1 -1
  37. package/dist/reconciler/registry.d.ts +22 -7
  38. package/dist/reconciler/registry.d.ts.map +1 -1
  39. package/dist/reconciler/registry.js +25 -15
  40. package/dist/reconciler/registry.js.map +1 -1
  41. package/dist/reconciler/root.d.ts +2 -2
  42. package/dist/reconciler/root.d.ts.map +1 -1
  43. package/dist/reconciler/root.js +1 -4
  44. package/dist/reconciler/root.js.map +1 -1
  45. package/dist/reconciler/signals.d.ts +3 -4
  46. package/dist/reconciler/signals.d.ts.map +1 -1
  47. package/dist/reconciler/signals.js +13 -25
  48. package/dist/reconciler/signals.js.map +1 -1
  49. package/dist/reconciler/text.d.ts.map +1 -1
  50. package/dist/reconciler/text.js +13 -8
  51. package/dist/reconciler/text.js.map +1 -1
  52. package/package.json +4 -4
  53. package/src/adw/element-behaviors.ts +6 -36
  54. package/src/adw/element-config.ts +27 -36
  55. package/src/components/window.tsx +15 -2
  56. package/src/config.ts +8 -1
  57. package/src/element-config.ts +3 -0
  58. package/src/hooks/use-signal.ts +4 -0
  59. package/src/internal.ts +1 -0
  60. package/src/reconciler/apply-props.ts +12 -6
  61. package/src/reconciler/behaviors.ts +8 -2
  62. package/src/reconciler/host-config.ts +2 -7
  63. package/src/reconciler/placement.ts +29 -9
  64. package/src/reconciler/registry.ts +31 -17
  65. package/src/reconciler/root.ts +3 -6
  66. package/src/reconciler/signals.ts +15 -36
  67. package/src/reconciler/text.ts +15 -8
@@ -1,5 +1,18 @@
1
- import { type ElementConfig, internal, type ModuleExport } from "../reconciler/registry.js";
1
+ import { type ElementConfig, forTypes, internal, type ModuleExport } from "../reconciler/registry.js";
2
2
 
3
+ const CHILD_SETTER_TYPES: string[] = [
4
+ "AdwBin",
5
+ "AdwClamp",
6
+ "AdwClampScrollable",
7
+ "AdwNavigationPage",
8
+ "AdwSplitButton",
9
+ "AdwStatusPage",
10
+ "AdwTabOverview",
11
+ "AdwToastOverlay",
12
+ "AdwToggle",
13
+ ];
14
+
15
+ const CONTENT_SETTER_TYPES: string[] = ["AdwBottomSheet", "AdwFlap", "AdwOverlaySplitView"];
3
16
  const childrenProps = internal("ChildrenProps");
4
17
  const breakpointsProps = adw("AdwBreakpointsProps");
5
18
  const preferencesRowProps = adw("AdwPreferencesRowProps");
@@ -10,42 +23,14 @@ const preferencesRowProps = adw("AdwPreferencesRowProps");
10
23
  * parent. Codegen imports this module, so it must never reach the GObject bindings.
11
24
  */
12
25
  const BUILTIN_ELEMENTS: Record<string, ElementConfig> = {
13
- AdwBin: {
14
- props: childrenProps,
15
- },
16
- AdwClamp: {
17
- props: childrenProps,
18
- },
19
- AdwClampScrollable: {
20
- props: childrenProps,
21
- },
22
- AdwNavigationPage: {
23
- props: childrenProps,
24
- },
25
- AdwSplitButton: {
26
+ ...forTypes(CHILD_SETTER_TYPES, {
26
27
  props: childrenProps,
27
- },
28
- AdwStatusPage: {
29
- props: childrenProps,
30
- },
31
- AdwTabOverview: {
28
+ omitProps: ["child"],
29
+ }),
30
+ ...forTypes(CONTENT_SETTER_TYPES, {
32
31
  props: childrenProps,
33
- },
34
- AdwToastOverlay: {
35
- props: childrenProps,
36
- },
37
- AdwToggle: {
38
- props: childrenProps,
39
- },
40
- AdwBottomSheet: {
41
- props: childrenProps,
42
- },
43
- AdwFlap: {
44
- props: childrenProps,
45
- },
46
- AdwOverlaySplitView: {
47
- props: childrenProps,
48
- },
32
+ omitProps: ["content"],
33
+ }),
49
34
  AdwViewStackPage: {
50
35
  lazy: true,
51
36
  },
@@ -58,15 +43,19 @@ const BUILTIN_ELEMENTS: Record<string, ElementConfig> = {
58
43
  AdwDialog: {
59
44
  props: breakpointsProps,
60
45
  component: adw("createDialogComponent"),
46
+ omitProps: ["child"],
61
47
  },
62
48
  AdwApplicationWindow: {
63
49
  props: breakpointsProps,
50
+ omitProps: ["content"],
64
51
  },
65
52
  AdwWindow: {
66
53
  props: breakpointsProps,
54
+ omitProps: ["content"],
67
55
  },
68
56
  AdwBreakpointBin: {
69
57
  props: breakpointsProps,
58
+ omitProps: ["child"],
70
59
  },
71
60
  AdwActionRow: {
72
61
  props: preferencesRowProps,
@@ -79,6 +68,7 @@ const BUILTIN_ELEMENTS: Record<string, ElementConfig> = {
79
68
  },
80
69
  AdwNavigationSplitView: {
81
70
  props: childrenProps,
71
+ omitProps: ["content"],
82
72
  },
83
73
  AdwLeaflet: {
84
74
  props: childrenProps,
@@ -115,6 +105,7 @@ const BUILTIN_ELEMENTS: Record<string, ElementConfig> = {
115
105
  },
116
106
  AdwToolbarView: {
117
107
  props: adw("AdwToolbarViewProps"),
108
+ omitProps: ["content"],
118
109
  },
119
110
  AdwHeaderBar: {
120
111
  props: internal("GtkHeaderBarProps"),
@@ -137,4 +128,4 @@ function adw(name: string): ModuleExport {
137
128
  return { module: "@gtkx/react/adw", export: name };
138
129
  }
139
130
 
140
- export { BUILTIN_ELEMENTS };
131
+ export { CHILD_SETTER_TYPES, CONTENT_SETTER_TYPES, BUILTIN_ELEMENTS };
@@ -2,11 +2,24 @@ import type * as Gtk from "@gtkx/gi/gtk";
2
2
  import { type ElementType, type ReactNode, type Ref, useLayoutEffect, useState } from "react";
3
3
  import { useMergedRef } from "../hooks/use-merged-refs.js";
4
4
  import { ParentWindowContext } from "../hooks/use-parent-window.js";
5
+ import { applyWrite } from "../reconciler/signals.js";
5
6
 
6
7
  type WindowComponentProps = {
7
8
  ref?: Ref<Gtk.Window | null> | undefined;
8
9
  };
9
10
 
11
+ const presentWindow = (window: Gtk.Window): void => {
12
+ applyWrite(() => {
13
+ window.present();
14
+ });
15
+ };
16
+
17
+ const destroyWindow = (window: Gtk.Window): void => {
18
+ applyWrite(() => {
19
+ window.destroy();
20
+ });
21
+ };
22
+
10
23
  const createWindowComponent = (Component: ElementType): ((props: WindowComponentProps) => ReactNode) => {
11
24
  return ({ ref, ...rest }: WindowComponentProps): ReactNode => {
12
25
  const [window, setWindow] = useState<Gtk.Window | null>(null);
@@ -17,10 +30,10 @@ const createWindowComponent = (Component: ElementType): ((props: WindowComponent
17
30
  return;
18
31
  }
19
32
 
20
- window.present();
33
+ presentWindow(window);
21
34
 
22
35
  return () => {
23
- window.destroy();
36
+ destroyWindow(window);
24
37
  };
25
38
  }, [window]);
26
39
 
package/src/config.ts CHANGED
@@ -1,3 +1,10 @@
1
1
  export { BUILTIN_ELEMENTS } from "./element-config.js";
2
2
  export type { DetachInfo, ElementBehavior, ElementConfig, ModuleExport, PlaceInfo } from "./reconciler/registry.js";
3
- export { defineElements, ELEMENTS, forTypes, internal, mergeElementConfigs } from "./reconciler/registry.js";
3
+ export {
4
+ defineBehavior,
5
+ defineElements,
6
+ ELEMENTS,
7
+ forTypes,
8
+ internal,
9
+ mergeElementConfigs,
10
+ } from "./reconciler/registry.js";
@@ -32,6 +32,7 @@ const SINGLE_CHILD_TYPES: string[] = [
32
32
  const BUILTIN_ELEMENTS: Record<string, ElementConfig> = {
33
33
  ...forTypes(SINGLE_CHILD_TYPES, {
34
34
  props: internal("ChildrenProps"),
35
+ omitProps: ["child"],
35
36
  }),
36
37
  ...forTypes(["GtkHeaderBar", "GtkActionBar"], {
37
38
  props: internal("GtkHeaderBarProps"),
@@ -39,6 +40,7 @@ const BUILTIN_ELEMENTS: Record<string, ElementConfig> = {
39
40
  GtkWindow: {
40
41
  props: internal("ChildrenProps"),
41
42
  component: internal("createWindowComponent"),
43
+ omitProps: ["child"],
42
44
  },
43
45
  GtkLabel: {
44
46
  props: internal("ChildrenProps"),
@@ -84,6 +86,7 @@ const BUILTIN_ELEMENTS: Record<string, ElementConfig> = {
84
86
  },
85
87
  GtkOverlay: {
86
88
  props: internal("GtkOverlayProps"),
89
+ omitProps: ["child"],
87
90
  },
88
91
  GtkShortcutController: {
89
92
  props: internal("GtkShortcutControllerProps"),
@@ -25,6 +25,10 @@ type UseSignalOptions = {
25
25
  * Each emission runs the handler from the latest render, so it does not have to be stable and a
26
26
  * changing handler never reconnects the signal.
27
27
  *
28
+ * On React 19.2 this does not hold inside a component wrapped in `memo` or `forwardRef`, where every emission
29
+ * runs the handler captured on the first render. Keep the calling component unwrapped, or read the values the
30
+ * handler needs off the GObject itself. React fixes this on the 19.3 line.
31
+ *
28
32
  * @param object The GObject (or ref to one) to connect to.
29
33
  * @param signal The signal name, optionally with a detail suffix.
30
34
  * @param handler The callback invoked when the signal is emitted.
package/src/internal.ts CHANGED
@@ -9,5 +9,6 @@ export { useObjectValue } from "./hooks/use-object-value.js";
9
9
  export type * from "./prop-types.js";
10
10
  export { isRootElement } from "./reconciler/root-element.js";
11
11
  export { createReconcilerRoot, type ReconcilerRoot, setReconcilerErrorHandler } from "./reconciler/root.js";
12
+ export { applyWrite } from "./reconciler/signals.js";
12
13
  export { getAccessibleMetadata } from "./utils/accessible-metadata.js";
13
14
  export { type RefProp, resolveRefProp } from "./utils/ref-prop.js";
@@ -6,7 +6,7 @@ import type { ElementBehavior, Props } from "./registry.js";
6
6
  import { applyAccessibleProps, isAccessibleProp } from "../utils/accessible-props.js";
7
7
  import { type TypeInfo, typeInfoFor } from "./metadata.js";
8
8
  import { type ElementNode, getOrCreateContext, type SignalTarget } from "./node.js";
9
- import { connectHandler, disconnectHandler } from "./signals.js";
9
+ import { applyWrite, connectHandler, disconnectHandler } from "./signals.js";
10
10
  import { bufferText, hasSameText, isContentPaintableProp, markTextDirty, TEXT_PROP } from "./text.js";
11
11
 
12
12
  type PropDelta = { name: string; value: unknown; prevValue: unknown };
@@ -43,7 +43,9 @@ const writeValue = (object: GObject.Object, name: string, value: unknown): void
43
43
  return;
44
44
  }
45
45
 
46
- Reflect.set(object, name, value);
46
+ applyWrite(() => {
47
+ Reflect.set(object, name, value);
48
+ });
47
49
  };
48
50
 
49
51
  const resetPlain = (object: GObject.Object, info: TypeInfo, name: string): void => {
@@ -65,9 +67,11 @@ const applyBufferText = (buffer: Gtk.TextBuffer, text: string): void => {
65
67
  return;
66
68
  }
67
69
 
68
- buffer.beginIrreversibleAction();
69
- buffer.setText(text, -1);
70
- buffer.endIrreversibleAction();
70
+ applyWrite(() => {
71
+ buffer.beginIrreversibleAction();
72
+ buffer.setText(text, -1);
73
+ buffer.endIrreversibleAction();
74
+ });
71
75
  };
72
76
 
73
77
  const isBufferText = (node: ElementNode, name: string): node is ElementNode & { object: Gtk.TextBuffer } =>
@@ -169,7 +173,9 @@ const restoreActionableSensitivity = (node: ElementNode, info: TypeInfo, prev: P
169
173
  const desired = "sensitive" in next ? next.sensitive : info.defaults.sensitive;
170
174
 
171
175
  if (typeof desired === "boolean") {
172
- Reflect.set(node.object, "sensitive", desired);
176
+ applyWrite(() => {
177
+ Reflect.set(node.object, "sensitive", desired);
178
+ });
173
179
  }
174
180
  };
175
181
 
@@ -3,6 +3,7 @@ import type * as Gtk from "@gtkx/gi/gtk";
3
3
  import { getInstanceType, TYPE_INVALID, typeFromName, typeIsA } from "@gtkx/runtime";
4
4
  import { getOrInsert, isDeepEqual, structuredClone } from "@gtkx/utils";
5
5
  import type { DetachInfo, ElementBehavior, PlaceInfo, Props } from "./registry.js";
6
+ import { applyWrite } from "./signals.js";
6
7
  import { hasSameText } from "./text.js";
7
8
 
8
9
  type SlotHooks<P extends GObject.Object, C extends GObject.Object> = {
@@ -176,7 +177,10 @@ const flushDeferred = <P extends GObject.Object, V>(
176
177
  return;
177
178
  }
178
179
 
179
- Reflect.set(object, prop, state.desired);
180
+ applyWrite(() => {
181
+ Reflect.set(object, prop, state.desired);
182
+ });
183
+
180
184
  state.applied = state.desired;
181
185
  };
182
186
 
@@ -203,7 +207,9 @@ const deferred = <P extends GObject.Object, V>(
203
207
  const controlledText = (prop: string): ElementBehavior =>
204
208
  value(prop, (object, next) => {
205
209
  if (!hasSameText(object, prop, next)) {
206
- Reflect.set(object, prop, next);
210
+ applyWrite(() => {
211
+ Reflect.set(object, prop, next);
212
+ });
207
213
  }
208
214
  });
209
215
 
@@ -32,7 +32,7 @@ import {
32
32
  type TextNode,
33
33
  } from "./node.js";
34
34
  import { isRootElement, type RootElement } from "./root-element.js";
35
- import { beginSuppression, disconnectAllHandlers, endSuppression } from "./signals.js";
35
+ import { disconnectAllHandlers } from "./signals.js";
36
36
  import {
37
37
  didUpdateTextSurgically,
38
38
  enclosingHost,
@@ -88,15 +88,10 @@ const hostConfig = {
88
88
  getRootHostContext: (): Record<string, never> => HOST_CONTEXT,
89
89
  getChildHostContext: (parent: Record<string, never>): Record<string, never> => parent,
90
90
  getPublicInstance: (instance: Instance): object => getPublicInstance(instance),
91
- prepareForCommit: (): null => {
92
- beginSuppression();
93
-
94
- return null;
95
- },
91
+ prepareForCommit: (): null => null,
96
92
  resetAfterCommit: (): void => {
97
93
  flushTextHosts();
98
94
  flushBehaviors();
99
- endSuppression();
100
95
  },
101
96
  preparePortalMount: (): void => undefined,
102
97
  clearContainer: (): void => undefined,
@@ -15,6 +15,7 @@ import {
15
15
  type PlaceableNode,
16
16
  type PlacedChild,
17
17
  } from "./node.js";
18
+ import { applyWrite } from "./signals.js";
18
19
  import { markTextDirty } from "./text.js";
19
20
 
20
21
  type AttachContext = { parent: ElementNode; entry: PlacedChild; index: number; sibling: GObject.Object | null };
@@ -69,8 +70,14 @@ const wireBufferView = (node: PlaceableNode, parent: ElementNode): void => {
69
70
  markTextDirty(node);
70
71
  };
71
72
 
73
+ const writeSlot = (parent: ElementNode, entry: PlacedChild, value: GObject.Object | null): void => {
74
+ applyWrite(() => {
75
+ Reflect.set(parent.object, entry.slot, value);
76
+ });
77
+ };
78
+
72
79
  const setObjectSlot = (parent: ElementNode, entry: PlacedChild): void => {
73
- Reflect.set(parent.object, entry.slot, entry.object);
80
+ writeSlot(parent, entry, entry.object);
74
81
  wireBufferView(entry.node, parent);
75
82
  entry.behavior = null;
76
83
  entry.attached = true;
@@ -98,7 +105,7 @@ const didAttach = (ctx: AttachContext, behavior: ElementBehavior): boolean => {
98
105
  return true;
99
106
  };
100
107
 
101
- const attachEntry = (parent: ElementNode, entry: PlacedChild, index: number, sibling: GObject.Object | null): void => {
108
+ const runAttach = (parent: ElementNode, entry: PlacedChild, index: number, sibling: GObject.Object | null): void => {
102
109
  const ctx: AttachContext = { parent, entry, index, sibling };
103
110
 
104
111
  for (const behavior of typeInfoFor(parent.typeName).behaviors) {
@@ -112,6 +119,12 @@ const attachEntry = (parent: ElementNode, entry: PlacedChild, index: number, sib
112
119
  }
113
120
  };
114
121
 
122
+ const attachEntry = (parent: ElementNode, entry: PlacedChild, index: number, sibling: GObject.Object | null): void => {
123
+ applyWrite(() => {
124
+ runAttach(parent, entry, index, sibling);
125
+ });
126
+ };
127
+
115
128
  const detachInfo = (entry: PlacedChild, context: unknown): DetachInfo => ({
116
129
  slot: entry.slot,
117
130
  adopted: entry.adopted,
@@ -119,17 +132,12 @@ const detachInfo = (entry: PlacedChild, context: unknown): DetachInfo => ({
119
132
  context,
120
133
  });
121
134
 
122
- const detachEntry = (parent: ElementNode, entry: PlacedChild): void => {
123
- if (!entry.attached) {
124
- return;
125
- }
126
-
127
- entry.attached = false;
135
+ const runDetach = (parent: ElementNode, entry: PlacedChild): void => {
128
136
  const behavior = entry.behavior;
129
137
 
130
138
  if (behavior === null) {
131
139
  if (entry.slot !== DEFAULT_SLOT) {
132
- Reflect.set(parent.object, entry.slot, null);
140
+ writeSlot(parent, entry, null);
133
141
  }
134
142
 
135
143
  return;
@@ -138,6 +146,18 @@ const detachEntry = (parent: ElementNode, entry: PlacedChild): void => {
138
146
  behavior.detach?.(parent.object, entry.object, detachInfo(entry, getOrCreateContext(parent, behavior)));
139
147
  };
140
148
 
149
+ const detachEntry = (parent: ElementNode, entry: PlacedChild): void => {
150
+ if (!entry.attached) {
151
+ return;
152
+ }
153
+
154
+ entry.attached = false;
155
+
156
+ applyWrite(() => {
157
+ runDetach(parent, entry);
158
+ });
159
+ };
160
+
141
161
  const rebuild = (parent: ElementNode, entries: PlacedChild[]): void => {
142
162
  for (const entry of entries) {
143
163
  detachEntry(parent, entry);
@@ -50,14 +50,17 @@ type ModuleExport = { module: string; export: string };
50
50
  /**
51
51
  * Per-element configuration keyed by GLib type name: whether the element is lazy (its GObject is
52
52
  * created by its parent container, as pages and layout children are), the custom behaviors bound to
53
- * its type, an optional component that wraps the generated element, and the base props interface its
54
- * generated props extend. `component` and `props` are inert at runtime; they are read only by codegen.
53
+ * its type, an optional component that wraps the generated element, the base props interface its
54
+ * generated props extend, and the GObject properties to leave out of the generated props (those a
55
+ * behavior already writes from children, such as the `child` and `content` properties). `component`,
56
+ * `props` and `omitProps` are inert at runtime; they are read only by codegen.
55
57
  */
56
58
  type ElementConfig<T extends GObject.Object = GObject.Object> = {
57
59
  lazy?: boolean;
58
60
  behaviors?: ElementBehavior<T>[];
59
61
  component?: ModuleExport;
60
62
  props?: ModuleExport;
63
+ omitProps?: string[];
61
64
  };
62
65
 
63
66
  /**
@@ -76,22 +79,19 @@ const mergeBehaviors = (base: ElementConfig, added: ElementBehavior[], isPrepend
76
79
  };
77
80
 
78
81
  const mergeConfigEntry = (base: ElementConfig, added: ElementConfig<never>, isPrepended = false): ElementConfig => {
79
- const entry: ElementConfig = { ...base };
82
+ const { behaviors, lazy, omitProps, ...rest } = added;
83
+ const entry: ElementConfig = { ...base, ...rest };
80
84
 
81
- if (added.behaviors !== undefined) {
82
- entry.behaviors = mergeBehaviors(entry, added.behaviors as ElementBehavior[], isPrepended);
85
+ if (behaviors !== undefined) {
86
+ entry.behaviors = mergeBehaviors(base, behaviors as ElementBehavior[], isPrepended);
83
87
  }
84
88
 
85
- if (added.lazy === true) {
86
- entry.lazy = true;
87
- }
88
-
89
- if (added.component !== undefined) {
90
- entry.component = added.component;
89
+ if (omitProps !== undefined) {
90
+ entry.omitProps = [...(base.omitProps ?? []), ...omitProps];
91
91
  }
92
92
 
93
- if (added.props !== undefined) {
94
- entry.props = added.props;
93
+ if (lazy === true) {
94
+ entry.lazy = true;
95
95
  }
96
96
 
97
97
  return entry;
@@ -99,8 +99,9 @@ const mergeConfigEntry = (base: ElementConfig, added: ElementConfig<never>, isPr
99
99
 
100
100
  /**
101
101
  * Merges maps of {@link ElementConfig} keyed by GLib type name into one, concatenating each type's
102
- * behaviors in the order the maps are given (an earlier map's behaviors come first) and taking the last
103
- * lazy flag, component, and props seen. Use it to combine an app's element config with its behaviors.
102
+ * behaviors and omitted props in the order the maps are given (an earlier map's behaviors come first)
103
+ * and taking the last lazy flag, component, and props seen. Use it to combine an app's element config
104
+ * with its behaviors.
104
105
  */
105
106
  const mergeElementConfigs = (...maps: Record<string, ElementConfig<never>>[]): Record<string, ElementConfig> => {
106
107
  const merged: Record<string, ElementConfig> = {};
@@ -131,12 +132,24 @@ const registerElements = (
131
132
 
132
133
  /**
133
134
  * Identity helper that types the module named by the `elements` entry of `gtkx.config.ts`, enabling
134
- * editor autocompletion and type checking. Key each entry by GLib type name and annotate each hook's
135
- * object parameter with the concrete GObject class the behavior applies to.
135
+ * editor autocompletion and type checking. Key each entry by GLib type name and write each behavior
136
+ * with {@link defineBehavior} so its hooks receive the concrete GObject class.
136
137
  */
137
138
  const defineElements = (elements: Record<string, ElementConfig<never>>): Record<string, ElementConfig<never>> =>
138
139
  elements;
139
140
 
141
+ /**
142
+ * Types one behavior against the GObject class it applies to, so every hook's object parameter is
143
+ * inferred rather than annotated by hand. Pass the class as the type argument.
144
+ *
145
+ * ```ts
146
+ * defineElements({
147
+ * GtkFrame: { behaviors: [defineBehavior<Gtk.Frame>({ attach: (frame, child) => frame.setChild(child) })] },
148
+ * });
149
+ * ```
150
+ */
151
+ const defineBehavior = <T extends GObject.Object>(hooks: ElementBehavior<T>): ElementBehavior<never> => hooks;
152
+
140
153
  /** Spreads one config across many GLib type names. */
141
154
  const forTypes = (types: string[], config: ElementConfig<never>): Record<string, ElementConfig<never>> =>
142
155
  Object.fromEntries(types.map((type) => [type, config]));
@@ -150,6 +163,7 @@ export {
150
163
  mergeElementConfigs,
151
164
  registerElements,
152
165
  defineElements,
166
+ defineBehavior,
153
167
  forTypes,
154
168
  internal,
155
169
  type Props,
@@ -1,10 +1,9 @@
1
- import type { ErrorInfo, ReactNode } from "react";
1
+ import type { ErrorInfo, ReactNode, ReactPortal } from "react";
2
2
  import * as Gdk from "@gtkx/gi/gdk";
3
3
  import { createLogger, type Logger } from "@gtkx/utils";
4
4
  import { ConcurrentRoot } from "react-reconciler/constants.js";
5
5
  import { type Container, reconciler } from "./host-config.js";
6
6
  import { rootElement } from "./root-element.js";
7
- import { endSuppressionNow } from "./signals.js";
8
7
 
9
8
  type OpaqueRoot = { [opaqueRoot]: true };
10
9
 
@@ -59,12 +58,10 @@ function createErrorHandlerSlot(): ErrorHandlerSlot {
59
58
  const setReconcilerErrorHandler = (handler: ErrorHandler): ErrorHandler | null => errorHandlerSlot.set(handler);
60
59
 
61
60
  const rethrowUncaughtRenderError = (error: unknown): never => {
62
- endSuppressionNow();
63
61
  throw error;
64
62
  };
65
63
 
66
64
  const logCaughtRenderError = (error: unknown): void => {
67
- endSuppressionNow();
68
65
  log.error("caught render error", error);
69
66
  };
70
67
 
@@ -161,8 +158,8 @@ const quit = (): typeof Gdk.EVENT_STOP => {
161
158
  * @param key An optional stable key.
162
159
  * @returns A React portal.
163
160
  */
164
- const createPortal = (children: ReactNode, container: Container, key?: string): ReactNode =>
165
- Object.assign(reconciler.createPortal(children, container, null, key ?? null), { type: "gtkx-portal", props: {} });
161
+ const createPortal = (children: ReactNode, container: Container, key?: string): ReactPortal =>
162
+ reconciler.createPortal(children, container, null, key ?? null) as unknown as ReactPortal;
166
163
 
167
164
  export {
168
165
  setReconcilerErrorHandler,
@@ -3,40 +3,20 @@ import { camelCase } from "@gtkx/utils";
3
3
  import type { HandlerRecord, SignalTarget } from "./node.js";
4
4
  import { type TypeInfo, typeInfoFor } from "./metadata.js";
5
5
 
6
- type SuppressionCounter = {
7
- beginSuppression: () => void;
8
- endSuppression: () => void;
9
- endSuppressionNow: () => void;
10
- isSuppressed: () => boolean;
11
- };
12
-
13
6
  const NOTIFY_DETAIL_PREFIX = "notify::";
14
- const suppressionCounter: SuppressionCounter = createSuppressionCounter();
15
- const beginSuppression: () => void = suppressionCounter.beginSuppression;
16
- const endSuppression: () => void = suppressionCounter.endSuppression;
17
- const endSuppressionNow: () => void = suppressionCounter.endSuppressionNow;
18
- const isSuppressed: () => boolean = suppressionCounter.isSuppressed;
19
-
20
- function createSuppressionCounter(): SuppressionCounter {
21
- let depth = 0;
22
-
23
- const release = (): void => {
24
- if (depth > 0) {
25
- depth -= 1;
26
- }
27
- };
7
+ const writeState: { depth: number } = { depth: 0 };
28
8
 
29
- return {
30
- beginSuppression: () => {
31
- depth += 1;
32
- },
33
- endSuppression: () => {
34
- queueMicrotask(release);
35
- },
36
- endSuppressionNow: release,
37
- isSuppressed: () => depth > 0,
38
- };
39
- }
9
+ const isApplyingWrite = (): boolean => writeState.depth > 0;
10
+
11
+ const applyWrite = <T>(write: () => T): T => {
12
+ writeState.depth += 1;
13
+
14
+ try {
15
+ return write();
16
+ } finally {
17
+ writeState.depth = Math.max(0, writeState.depth - 1);
18
+ }
19
+ };
40
20
 
41
21
  const getNotifyProperty = (signal: string): string | null =>
42
22
  signal.startsWith(NOTIFY_DETAIL_PREFIX) ? camelCase(signal.slice(NOTIFY_DETAIL_PREFIX.length)) : null;
@@ -56,7 +36,7 @@ const invokeHandler = (
56
36
 
57
37
  const wrapHandler = (target: SignalTarget, record: HandlerRecord, notifyProperty: string | null): SignalHandler =>
58
38
  (...args: unknown[]): unknown => {
59
- if (record.blockable && isSuppressed()) {
39
+ if (record.blockable && isApplyingWrite()) {
60
40
  return undefined;
61
41
  }
62
42
 
@@ -104,9 +84,8 @@ const disconnectAllHandlers = (target: SignalTarget): void => {
104
84
  };
105
85
 
106
86
  export {
107
- beginSuppression,
108
- endSuppression,
109
- endSuppressionNow,
87
+ applyWrite,
88
+ isApplyingWrite,
110
89
  connectHandler,
111
90
  disconnectHandler,
112
91
  disconnectAllHandlers,
@@ -5,6 +5,7 @@ import { drain, indexBeforeOrEnd } from "@gtkx/utils";
5
5
  import type { ContentChild, ContentKind, ElementNode, ParentNode, TextNode } from "./node.js";
6
6
  import type { Props } from "./registry.js";
7
7
  import { ELEMENT_KIND, TEXT_KIND } from "./node.js";
8
+ import { applyWrite } from "./signals.js";
8
9
 
9
10
  type OffsetResult = { found: boolean; offset: number };
10
11
 
@@ -319,13 +320,15 @@ const rebuildBuffer = (node: ElementNode): void => {
319
320
  return;
320
321
  }
321
322
 
322
- buffer.setText("", -1);
323
- const build: BufferBuild = { buffer, view: node.bufferView, marks: [] };
324
- insertContent(build, node.content);
323
+ applyWrite(() => {
324
+ buffer.setText("", -1);
325
+ const build: BufferBuild = { buffer, view: node.bufferView, marks: [] };
326
+ insertContent(build, node.content);
325
327
 
326
- for (const mark of build.marks) {
327
- placeMark(buffer, mark.node, mark.offset);
328
- }
328
+ for (const mark of build.marks) {
329
+ placeMark(buffer, mark.node, mark.offset);
330
+ }
331
+ });
329
332
  };
330
333
 
331
334
  const rebuildLabel = (node: ElementNode): void => {
@@ -381,8 +384,12 @@ const didUpdateTextSurgically = (host: ElementNode, node: TextNode, oldText: str
381
384
  }
382
385
 
383
386
  const start = located.offset;
384
- buffer.delete(buffer.getIterAtOffset(start), buffer.getIterAtOffset(start + charLength(oldText)));
385
- buffer.insert(buffer.getIterAtOffset(start), newText, -1);
387
+
388
+ applyWrite(() => {
389
+ buffer.delete(buffer.getIterAtOffset(start), buffer.getIterAtOffset(start + charLength(oldText)));
390
+ buffer.insert(buffer.getIterAtOffset(start), newText, -1);
391
+ });
392
+
386
393
  const startIter = buffer.getIterAtOffset(start);
387
394
  const endIter = buffer.getIterAtOffset(start + charLength(newText));
388
395
  applyEnclosingTags(buffer, node, startIter, endIter);