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 +19 -10
- package/package.json +1 -1
- package/src/__tests__/mocks.ts +14 -1
- package/src/__tests__/portal.test.tsx +85 -0
- package/src/__tests__/renderer.test.tsx +51 -1
- package/src/host-config.ts +40 -2
- package/src/index.ts +5 -1
- package/src/portal.tsx +51 -0
- package/src/renderer.ts +43 -2
- package/src/types.ts +1 -0
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
|
-
|
|
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 {
|
|
126
|
+
import { Portal, View } from "onejs-react"
|
|
123
127
|
|
|
124
128
|
function Modal({ children }) {
|
|
125
|
-
return
|
|
126
|
-
<
|
|
127
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
package/src/__tests__/mocks.ts
CHANGED
|
@@ -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', () => {
|
package/src/host-config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|