onejs-react 0.1.26 → 0.1.28

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
@@ -98,6 +98,12 @@ import { render, RenderContainer } from "onejs-react"
98
98
  render(<App />, __root)
99
99
  ```
100
100
 
101
+ ### Unmounting & hot-reload teardown
102
+
103
+ `unmount(container)` tears a root down **synchronously** (`updateContainerSync` + `flushSyncWork` + `flushPassiveEffects`), so `useEffect`/`useLayoutEffect` cleanup functions fire immediately instead of on a later scheduler tick. `unmountAll()` does the same for every active root.
104
+
105
+ The first `render()` call registers `unmountAll` as a runtime teardown hook (`globalThis.__onTeardown`). The OneJS runtime invokes it right before destroying the JS context on hot reload / stop, so component cleanups run while the context is still alive. Without this, cleanups would be skipped on hot reload and stale C# subscriptions (e.g. from `useEventSync`) would leak across reloads.
106
+
101
107
  ### Type Hierarchy
102
108
 
103
109
  ```
@@ -114,24 +120,27 @@ RenderContainer (minimal: __csHandle, __csType)
114
120
 
115
121
  ### Portals
116
122
 
117
- `createPortal(children, container, key?)` renders a subtree into a different `VisualElement`, outside the normal parent hierarchy - the OneJS equivalent of `react-dom`'s `createPortal`.
118
-
119
- UI Toolkit has no `z-index`: paint order follows the element hierarchy and a parent with `overflow: hidden` clips its children. Portaling overlays (modals, tooltips, dropdowns) into a top-level container like `__root` lets them escape clipping and paint above the rest of the UI.
123
+ `<Portal>` renders children above the rest of the UI (modals, tooltips, dropdowns). UI Toolkit has no `z-index` and `overflow: hidden` clips children, so a deeply-nested overlay gets clipped and stuck below later siblings. `<Portal>` mounts into a shared overlay layer that onejs-react keeps as the last child of `__root`, so overlays always paint on top. Zero setup.
120
124
 
121
125
  ```tsx
122
- import { createPortal, View } from "onejs-react"
126
+ import { Portal, View } from "onejs-react"
123
127
 
124
128
  function Modal({ children }) {
125
- return createPortal(
126
- <View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}>
127
- {children}
128
- </View>,
129
- __root
129
+ return (
130
+ <Portal>
131
+ <View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}>
132
+ {children}
133
+ </View>
134
+ </Portal>
130
135
  )
131
136
  }
132
137
  ```
133
138
 
134
- > Use the `createPortal` exported from `onejs-react`, not the one from `react-dom` - the latter targets the browser DOM and will not work here.
139
+ The layer ignores picking when empty, so a closed overlay never blocks the app.
140
+
141
+ `<Portal>` is built on `createPortal(children, container, key?)` (the OneJS equivalent of `react-dom`'s `createPortal`), exported for when you need a specific target. With a custom target you own draw order, so prefer `<Portal>` for overlays.
142
+
143
+ > Use the exports from `onejs-react`, not `react-dom` - the latter targets the browser DOM and will not work here.
135
144
 
136
145
  ## Key Concepts
137
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-react",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -60,6 +60,7 @@ export class MockVisualElement {
60
60
 
61
61
  // Child management
62
62
  private _children: MockVisualElement[] = [];
63
+ private _parent: MockVisualElement | null = null;
63
64
 
64
65
  // Style object (simulates IStyle)
65
66
  style: Record<string, unknown> = {};
@@ -73,6 +74,7 @@ export class MockVisualElement {
73
74
  value: unknown = undefined;
74
75
  label = '';
75
76
  enabledSelf = true;
77
+ pickingMode = 0;
76
78
 
77
79
  constructor(csType = 'UnityEngine.UIElements.VisualElement') {
78
80
  this.__csHandle = Math.floor(Math.random() * 1000000);
@@ -84,6 +86,7 @@ export class MockVisualElement {
84
86
  Add(child: MockVisualElement): void {
85
87
  if (child && !this._children.includes(child)) {
86
88
  this._children.push(child);
89
+ child._parent = this;
87
90
  }
88
91
  }
89
92
 
@@ -95,6 +98,7 @@ export class MockVisualElement {
95
98
  this._children.splice(existingIndex, 1);
96
99
  }
97
100
  this._children.splice(index, 0, child);
101
+ child._parent = this;
98
102
  }
99
103
  }
100
104
 
@@ -102,20 +106,29 @@ export class MockVisualElement {
102
106
  const index = this._children.indexOf(child);
103
107
  if (index >= 0) {
104
108
  this._children.splice(index, 1);
109
+ child._parent = null;
105
110
  }
106
111
  }
107
112
 
108
113
  RemoveAt(index: number): void {
109
114
  if (index >= 0 && index < this._children.length) {
110
- this._children.splice(index, 1);
115
+ const [child] = this._children.splice(index, 1);
116
+ if (child) child._parent = null;
111
117
  }
112
118
  }
113
119
 
120
+ // Mirrors UnityEngine.UIElements.VisualElement.RemoveFromHierarchy(): detach from
121
+ // the current parent, a no-op when already detached.
122
+ RemoveFromHierarchy(): void {
123
+ this._parent?.Remove(this);
124
+ }
125
+
114
126
  IndexOf(child: MockVisualElement): number {
115
127
  return this._children.indexOf(child);
116
128
  }
117
129
 
118
130
  Clear(): void {
131
+ for (const child of this._children) child._parent = null;
119
132
  this._children = [];
120
133
  }
121
134
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tests for the <Portal> component.
3
+ *
4
+ * Portal renders into a shared overlay layer that OneJS keeps as the last child
5
+ * of __root, so overlays paint on top of the app without per-call BringToFront.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import React from 'react';
10
+ import { render } from '../renderer';
11
+ import { Portal } from '../portal';
12
+ import { View, Label, Button } from '../components';
13
+ import { MockVisualElement, createMockContainer, flushMicrotasks } from './mocks';
14
+
15
+ describe('Portal', () => {
16
+ it('mounts children into a shared overlay layer appended last under __root', async () => {
17
+ const root = createMockContainer();
18
+ (globalThis as any).__root = root;
19
+
20
+ render(<View><Portal><Label text="overlay" /></Portal></View>, root as any);
21
+ await flushMicrotasks();
22
+
23
+ // root children: [0] app view, [1] portal layer (added in an effect → last)
24
+ expect(root.childCount).toBe(2);
25
+ const layer = root.children[1] as MockVisualElement;
26
+ expect(layer.name).toBe('onejs-portal-root');
27
+ expect(layer.pickingMode).toBe((globalThis as any).CS.UnityEngine.UIElements.PickingMode.Ignore);
28
+
29
+ // portal content lives inside the layer, not the app view
30
+ expect(layer.childCount).toBe(1);
31
+ expect(layer.children[0].__csType).toBe('UnityEngine.UIElements.Label');
32
+ expect((root.children[0] as MockVisualElement).childCount).toBe(0);
33
+ });
34
+
35
+ it('shares one layer across multiple Portals', async () => {
36
+ const root = createMockContainer();
37
+ (globalThis as any).__root = root;
38
+
39
+ render(
40
+ <View>
41
+ <Portal><Label text="a" /></Portal>
42
+ <Portal><Button text="b" /></Portal>
43
+ </View>,
44
+ root as any
45
+ );
46
+ await flushMicrotasks();
47
+
48
+ // app + exactly one shared layer
49
+ expect(root.childCount).toBe(2);
50
+ const layer = root.children[1] as MockVisualElement;
51
+ expect(layer.childCount).toBe(2);
52
+ });
53
+
54
+ it('targets an explicit container without creating the shared layer', async () => {
55
+ const root = createMockContainer();
56
+ (globalThis as any).__root = root;
57
+ const target = createMockContainer();
58
+
59
+ render(<View><Portal container={target as any}><Label text="x" /></Portal></View>, root as any);
60
+ await flushMicrotasks();
61
+
62
+ expect(target.childCount).toBe(1);
63
+ expect(target.children[0].__csType).toBe('UnityEngine.UIElements.Label');
64
+ // shared layer not created: root has only the app view
65
+ expect(root.childCount).toBe(1);
66
+ });
67
+
68
+ it('removes portal content when unmounted', async () => {
69
+ const root = createMockContainer();
70
+ (globalThis as any).__root = root;
71
+
72
+ function App({ show }: { show: boolean }) {
73
+ return <View>{show && <Portal><Label text="m" /></Portal>}</View>;
74
+ }
75
+
76
+ render(<App show={true} />, root as any);
77
+ await flushMicrotasks();
78
+ const layer = root.children[1] as MockVisualElement;
79
+ expect(layer.childCount).toBe(1);
80
+
81
+ render(<App show={false} />, root as any);
82
+ await flushMicrotasks();
83
+ expect(layer.childCount).toBe(0);
84
+ });
85
+ });
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { describe, it, expect, vi, beforeEach } from 'vitest';
12
12
  import React, { useState, useEffect } from 'react';
13
- import { render, unmount, createPortal, getRoot } from '../renderer';
13
+ import { render, unmount, unmountAll, createPortal, getRoot } from '../renderer';
14
14
  import { View, Label, Button } from '../components';
15
15
  import { MockVisualElement, MockLength, MockColor, createMockContainer, flushMicrotasks, getEventAPI } from './mocks';
16
16
 
@@ -402,6 +402,56 @@ describe('renderer', () => {
402
402
 
403
403
  expect(cleanupFn).toHaveBeenCalled();
404
404
  });
405
+
406
+ it('fires effect cleanup synchronously on unmount (no tick needed)', async () => {
407
+ // On hot-reload teardown the JS context is destroyed immediately after
408
+ // unmount, so cleanups must run synchronously rather than on a later tick.
409
+ const container = createMockContainer();
410
+ const cleanupFn = vi.fn();
411
+
412
+ function CleanupComponent() {
413
+ useEffect(() => cleanupFn, []);
414
+ return <ojs-view />;
415
+ }
416
+
417
+ render(<CleanupComponent />, container as any);
418
+ await flushMicrotasks();
419
+ expect(cleanupFn).not.toHaveBeenCalled();
420
+
421
+ unmount(container as any);
422
+ // No flushMicrotasks: cleanup must already have run.
423
+ expect(cleanupFn).toHaveBeenCalledTimes(1);
424
+ expect(getRoot(container as any)).toBeUndefined();
425
+ });
426
+
427
+ it('unmountAll tears down every root and fires cleanups (hot reload)', async () => {
428
+ // unmountAll is what the runtime teardown hook (__runTeardown) invokes
429
+ // before destroying the context on hot reload / stop.
430
+ const containerA = createMockContainer();
431
+ const containerB = createMockContainer();
432
+ const cleanupA = vi.fn();
433
+ const cleanupB = vi.fn();
434
+
435
+ function CompA() {
436
+ useEffect(() => cleanupA, []);
437
+ return <ojs-view />;
438
+ }
439
+ function CompB() {
440
+ useEffect(() => cleanupB, []);
441
+ return <ojs-view />;
442
+ }
443
+
444
+ render(<CompA />, containerA as any);
445
+ render(<CompB />, containerB as any);
446
+ await flushMicrotasks();
447
+
448
+ unmountAll();
449
+
450
+ expect(cleanupA).toHaveBeenCalledTimes(1);
451
+ expect(cleanupB).toHaveBeenCalledTimes(1);
452
+ expect(getRoot(containerA as any)).toBeUndefined();
453
+ expect(getRoot(containerB as any)).toBeUndefined();
454
+ });
405
455
  });
406
456
 
407
457
  describe('conditional rendering', () => {
@@ -86,6 +86,10 @@ declare const __eventAPI: {
86
86
  removeParent: (childHandle: number) => void;
87
87
  };
88
88
 
89
+ // Panel root element, provided by the runtime. This is also the container apps
90
+ // pass to render(), so it is the natural home for a shared overlay layer.
91
+ declare const __root: CSObject;
92
+
89
93
  interface CSStyle {
90
94
  [key: string]: unknown;
91
95
  }
@@ -437,6 +441,34 @@ function applyStyle(element: CSObject, style: ViewStyle | undefined): Set<string
437
441
  return appliedKeys;
438
442
  }
439
443
 
444
+ // Shared overlay layer for <Portal>. A full-screen, click-through element kept as
445
+ // the LAST child of __root, so portaled content (modals, tooltips, dropdowns)
446
+ // always paints above the app and escapes any `overflow: hidden` ancestor.
447
+ //
448
+ // It must be appended AFTER the app's root view exists, otherwise it lands before
449
+ // the app and renders behind it. Callers create it from an effect (post-mount), by
450
+ // which point the app root is already in __root, so Add() puts the layer last.
451
+ //
452
+ // Keyed by __root so a fresh root (e.g. after a domain reload) gets its own layer.
453
+ const _portalLayers = new WeakMap<object, VisualElement>();
454
+
455
+ export function getPortalLayer(): VisualElement {
456
+ const root = __root as unknown as object;
457
+ let layer = _portalLayers.get(root);
458
+ if (!layer) {
459
+ const el: CSObject = new CS.UnityEngine.UIElements.VisualElement();
460
+ el.name = "onejs-portal-root";
461
+ applyStyle(el, { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 });
462
+ // The layer itself ignores picking so empty regions stay click-through; its
463
+ // children (modals, etc.) still receive input normally.
464
+ el.pickingMode = CS.UnityEngine.UIElements.PickingMode.Ignore;
465
+ __root.Add(el);
466
+ layer = el;
467
+ _portalLayers.set(root, layer);
468
+ }
469
+ return layer;
470
+ }
471
+
440
472
  // Force-resolve CS path proxies (e.g. CS.UnityEngine.UIElements.Justify.Center)
441
473
  // to their underlying int value. parseEnumValue and parseLength's StyleKeyword
442
474
  // cases return path proxies whose .valueOf() reads the int via GetField. The
@@ -1100,14 +1132,20 @@ export const hostConfig = {
1100
1132
  removeMergedTextChild(parentInstance, child);
1101
1133
  } else {
1102
1134
  __eventAPI.removeAllEventListeners(child.element);
1103
- parentInstance.element.Remove(child.element);
1135
+ // RemoveFromHierarchy() detaches the element from its current parent and
1136
+ // is a safe no-op if it's already detached. Using it instead of
1137
+ // parentInstance.element.Remove(child.element) keeps unmount from throwing
1138
+ // when the root was cleared before React tore the tree down (hot reload).
1139
+ child.element.RemoveFromHierarchy();
1104
1140
  }
1105
1141
  untrackParent(child.element);
1106
1142
  },
1107
1143
 
1108
1144
  removeChildFromContainer(container: Container, child: Instance) {
1109
1145
  __eventAPI.removeAllEventListeners(child.element);
1110
- container.Remove(child.element);
1146
+ // See removeChild: tolerant of an already-detached element so a hot-reload
1147
+ // teardown (which clears the root first) can still unmount cleanly.
1148
+ child.element.RemoveFromHierarchy();
1111
1149
  untrackParent(child.element);
1112
1150
  },
1113
1151
 
package/src/index.ts CHANGED
@@ -19,7 +19,11 @@ export {
19
19
  export { registerElement } from './host-config';
20
20
 
21
21
  // Renderer
22
- export { render, unmount, createPortal, flushSync, batchedUpdates, getDebugInfo } from './renderer';
22
+ export { render, unmount, unmountAll, createPortal, flushSync, batchedUpdates, getDebugInfo } from './renderer';
23
+
24
+ // Portals
25
+ export { Portal } from './portal';
26
+ export type { PortalProps } from './portal';
23
27
 
24
28
  // Error Handling
25
29
  export { ErrorBoundary, formatError } from './error-boundary';
package/src/portal.tsx ADDED
@@ -0,0 +1,51 @@
1
+ import { useState, useEffect, type ReactNode } from "react"
2
+ import { createPortal } from "./renderer"
3
+ import { getPortalLayer } from "./host-config"
4
+ import type { RenderContainer } from "./types"
5
+
6
+ export interface PortalProps {
7
+ children: ReactNode
8
+ /** Target element. Defaults to OneJS's shared overlay layer (last child of `__root`). */
9
+ container?: RenderContainer
10
+ }
11
+
12
+ /**
13
+ * Render `children` above the rest of the UI, outside the normal hierarchy.
14
+ *
15
+ * Zero setup: portals into a shared overlay layer that OneJS keeps as the last
16
+ * child of `__root`, so modals, tooltips, and dropdowns always paint on top and
17
+ * escape any `overflow: hidden` ancestor. Pass `container` to target a specific
18
+ * element instead.
19
+ *
20
+ * Built on {@link createPortal}; reach for that directly only when you need a
21
+ * custom target and want to manage draw order yourself.
22
+ *
23
+ * @example
24
+ * function Modal({ onClose, children }) {
25
+ * return (
26
+ * <Portal>
27
+ * <View
28
+ * style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
29
+ * backgroundColor: "rgba(0,0,0,0.6)", alignItems: "center", justifyContent: "center" }}
30
+ * onClick={onClose}
31
+ * >
32
+ * <View onClick={(e) => e.stopPropagation()}>{children}</View>
33
+ * </View>
34
+ * </Portal>
35
+ * )
36
+ * }
37
+ */
38
+ export function Portal({ children, container }: PortalProps) {
39
+ // The shared layer is appended to __root in an effect (after the app mounts) so
40
+ // it lands last. It is therefore unavailable on the first render; we resolve it
41
+ // post-mount, which adds one frame before the overlay appears. An explicit
42
+ // container is used immediately, with no delay.
43
+ const [layer, setLayer] = useState<RenderContainer | null>(container ?? null)
44
+
45
+ useEffect(() => {
46
+ if (!container) setLayer(getPortalLayer())
47
+ }, [container])
48
+
49
+ const target = container ?? layer
50
+ return target ? createPortal(children, target) : null
51
+ }
package/src/renderer.ts CHANGED
@@ -19,7 +19,24 @@ reconciler.injectIntoDevTools({
19
19
  // Track roots for hot reload / re-render
20
20
  const roots = new Map<RenderContainer, ReturnType<typeof reconciler.createContainer>>();
21
21
 
22
+ // Register unmountAll as a runtime teardown hook exactly once. The OneJS runtime
23
+ // (QuickJSUIBridge.Dispose) invokes __runTeardown() right before destroying the JS
24
+ // context on hot reload / stop. Unmounting here fires useEffect/useLayoutEffect
25
+ // cleanups while the context is still alive; otherwise they never run and stale
26
+ // C# subscriptions (e.g. from useEventSync) leak across reloads.
27
+ let teardownHookRegistered = false;
28
+ function ensureTeardownHook(): void {
29
+ if (teardownHookRegistered) return;
30
+ const g = globalThis as any;
31
+ if (typeof g !== 'undefined' && typeof g.__onTeardown === 'function') {
32
+ g.__onTeardown(unmountAll);
33
+ teardownHookRegistered = true;
34
+ }
35
+ }
36
+
22
37
  export function render(element: ReactNode, container: RenderContainer): void {
38
+ ensureTeardownHook();
39
+
23
40
  let root = roots.get(container);
24
41
 
25
42
  if (!root) {
@@ -52,9 +69,33 @@ export function render(element: ReactNode, container: RenderContainer): void {
52
69
 
53
70
  export function unmount(container: RenderContainer): void {
54
71
  const root = roots.get(container);
55
- if (root) {
72
+ if (!root) return;
73
+ roots.delete(container);
74
+
75
+ const r = reconciler as any;
76
+ // Tear the tree down synchronously. updateContainer(null, ...) only *schedules*
77
+ // the unmount, which never gets a scheduler tick during a hot-reload teardown
78
+ // (the context is destroyed immediately after). updateContainerSync + flushSyncWork
79
+ // runs the commit now, firing useLayoutEffect cleanups during it.
80
+ if (typeof r.updateContainerSync === 'function') {
81
+ r.updateContainerSync(null, root, null, null);
82
+ if (typeof r.flushSyncWork === 'function') r.flushSyncWork();
83
+ } else {
56
84
  reconciler.updateContainer(null, root, null, () => {});
57
- roots.delete(container);
85
+ }
86
+ // useEffect (passive) cleanups are queued by the unmount commit, not run by it.
87
+ // Flush them now so they also fire before the context goes away.
88
+ if (typeof r.flushPassiveEffects === 'function') r.flushPassiveEffects();
89
+ }
90
+
91
+ /**
92
+ * Unmount every active root. Invoked by the OneJS runtime teardown hook before the
93
+ * JS context is destroyed (hot reload / stop) so component cleanups run.
94
+ */
95
+ export function unmountAll(): void {
96
+ // Snapshot keys first: unmount() mutates the roots map.
97
+ for (const container of Array.from(roots.keys())) {
98
+ unmount(container);
58
99
  }
59
100
  }
60
101
 
package/src/types.ts CHANGED
@@ -675,6 +675,7 @@ export interface VisualElement extends RenderContainer {
675
675
  Insert: (index: number, child: VisualElement) => void;
676
676
  Remove: (child: VisualElement) => void;
677
677
  RemoveAt: (index: number) => void;
678
+ RemoveFromHierarchy: () => void;
678
679
  Clear: () => void;
679
680
  IndexOf: (child: VisualElement) => number;
680
681
  childCount: number;