onejs-react 0.1.27 → 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 +6 -0
- package/package.json +1 -1
- package/src/__tests__/mocks.ts +13 -1
- package/src/__tests__/renderer.test.tsx +51 -1
- package/src/host-config.ts +8 -2
- package/src/index.ts +1 -1
- 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
|
```
|
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> = {};
|
|
@@ -85,6 +86,7 @@ export class MockVisualElement {
|
|
|
85
86
|
Add(child: MockVisualElement): void {
|
|
86
87
|
if (child && !this._children.includes(child)) {
|
|
87
88
|
this._children.push(child);
|
|
89
|
+
child._parent = this;
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
@@ -96,6 +98,7 @@ export class MockVisualElement {
|
|
|
96
98
|
this._children.splice(existingIndex, 1);
|
|
97
99
|
}
|
|
98
100
|
this._children.splice(index, 0, child);
|
|
101
|
+
child._parent = this;
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
@@ -103,20 +106,29 @@ export class MockVisualElement {
|
|
|
103
106
|
const index = this._children.indexOf(child);
|
|
104
107
|
if (index >= 0) {
|
|
105
108
|
this._children.splice(index, 1);
|
|
109
|
+
child._parent = null;
|
|
106
110
|
}
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
RemoveAt(index: number): void {
|
|
110
114
|
if (index >= 0 && index < this._children.length) {
|
|
111
|
-
this._children.splice(index, 1);
|
|
115
|
+
const [child] = this._children.splice(index, 1);
|
|
116
|
+
if (child) child._parent = null;
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
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
|
+
|
|
115
126
|
IndexOf(child: MockVisualElement): number {
|
|
116
127
|
return this._children.indexOf(child);
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
Clear(): void {
|
|
131
|
+
for (const child of this._children) child._parent = null;
|
|
120
132
|
this._children = [];
|
|
121
133
|
}
|
|
122
134
|
|
|
@@ -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
|
@@ -1132,14 +1132,20 @@ export const hostConfig = {
|
|
|
1132
1132
|
removeMergedTextChild(parentInstance, child);
|
|
1133
1133
|
} else {
|
|
1134
1134
|
__eventAPI.removeAllEventListeners(child.element);
|
|
1135
|
-
|
|
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();
|
|
1136
1140
|
}
|
|
1137
1141
|
untrackParent(child.element);
|
|
1138
1142
|
},
|
|
1139
1143
|
|
|
1140
1144
|
removeChildFromContainer(container: Container, child: Instance) {
|
|
1141
1145
|
__eventAPI.removeAllEventListeners(child.element);
|
|
1142
|
-
|
|
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();
|
|
1143
1149
|
untrackParent(child.element);
|
|
1144
1150
|
},
|
|
1145
1151
|
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ 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
23
|
|
|
24
24
|
// Portals
|
|
25
25
|
export { Portal } from './portal';
|
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;
|