onejs-react 0.1.27 → 0.1.29
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 +30 -1
- package/src/__tests__/painter.test.ts +103 -0
- package/src/__tests__/renderer.test.tsx +51 -1
- package/src/host-config.ts +48 -8
- package/src/index.ts +4 -1
- package/src/painter.ts +212 -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
|
```
|
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
|
|
|
@@ -356,6 +368,23 @@ export function createMockCS() {
|
|
|
356
368
|
}
|
|
357
369
|
},
|
|
358
370
|
},
|
|
371
|
+
// Mirrors the real CS.OneJS.NodeBridge: resolve element handles and
|
|
372
|
+
// delegate to the same tree ops the slow path would have called.
|
|
373
|
+
NodeBridge: {
|
|
374
|
+
Add: (parentHandle: number, childHandle: number) => {
|
|
375
|
+
const parent = findElementByHandle(parentHandle);
|
|
376
|
+
const child = findElementByHandle(childHandle);
|
|
377
|
+
if (parent && child) parent.Add(child);
|
|
378
|
+
},
|
|
379
|
+
Insert: (parentHandle: number, index: number, childHandle: number) => {
|
|
380
|
+
const parent = findElementByHandle(parentHandle);
|
|
381
|
+
const child = findElementByHandle(childHandle);
|
|
382
|
+
if (parent && child) parent.Insert(index, child);
|
|
383
|
+
},
|
|
384
|
+
RemoveFromHierarchy: (childHandle: number) => {
|
|
385
|
+
findElementByHandle(childHandle)?.RemoveFromHierarchy();
|
|
386
|
+
},
|
|
387
|
+
},
|
|
359
388
|
},
|
|
360
389
|
};
|
|
361
390
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest"
|
|
2
|
+
import { Painter, batchedVisualContent } from "../painter"
|
|
3
|
+
|
|
4
|
+
// Opcodes mirrored from painter.ts / PainterBridge.cs. These assertions are the
|
|
5
|
+
// guard that the JS recorder and the C# replay engine stay in sync.
|
|
6
|
+
const OP = {
|
|
7
|
+
BeginPath: 1, ClosePath: 2, MoveTo: 3, LineTo: 4, Arc: 5, ArcTo: 6,
|
|
8
|
+
Bezier: 7, Quad: 8, Fill: 9, Stroke: 10, LineWidth: 11, FillColor: 12,
|
|
9
|
+
StrokeColor: 13, LineCap: 14, LineJoin: 15, MiterLimit: 16,
|
|
10
|
+
DashOffset: 17, DashPattern: 18,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("Painter command buffer", () => {
|
|
14
|
+
let lastBuffer: Float32Array | null
|
|
15
|
+
let executeSpy: ReturnType<typeof vi.fn>
|
|
16
|
+
const mgc = {} as never
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
lastBuffer = null
|
|
20
|
+
executeSpy = vi.fn((_mgc: unknown, buffer: Float32Array) => {
|
|
21
|
+
lastBuffer = buffer
|
|
22
|
+
})
|
|
23
|
+
// setup.ts installs a fresh mock CS each test; augment it with PainterBridge.
|
|
24
|
+
;(globalThis as any).CS.OneJS.PainterBridge = { Execute: executeSpy }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("flushes recorded ops as a Float32Array and resets", () => {
|
|
28
|
+
const p = new Painter()
|
|
29
|
+
p.beginPath()
|
|
30
|
+
p.moveTo(10, 20)
|
|
31
|
+
p.lineTo(30, 40)
|
|
32
|
+
p.fill()
|
|
33
|
+
|
|
34
|
+
expect(p.length).toBe(9)
|
|
35
|
+
p.flush(mgc)
|
|
36
|
+
|
|
37
|
+
expect(executeSpy).toHaveBeenCalledTimes(1)
|
|
38
|
+
expect(lastBuffer).toBeInstanceOf(Float32Array)
|
|
39
|
+
expect(Array.from(lastBuffer!)).toEqual([
|
|
40
|
+
OP.BeginPath,
|
|
41
|
+
OP.MoveTo, 10, 20,
|
|
42
|
+
OP.LineTo, 30, 40,
|
|
43
|
+
OP.Fill, 0, // default rule = NonZero
|
|
44
|
+
])
|
|
45
|
+
// Buffer is reset after flush so the next repaint starts clean.
|
|
46
|
+
expect(p.length).toBe(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("applies defaults: arc direction Clockwise (0) and color alpha 1", () => {
|
|
50
|
+
const p = new Painter()
|
|
51
|
+
p.fillColor(0.5, 0.25, 0.125)
|
|
52
|
+
p.arc(100, 100, 80, 0, 0.5)
|
|
53
|
+
p.flush(mgc)
|
|
54
|
+
|
|
55
|
+
expect(Array.from(lastBuffer!)).toEqual([
|
|
56
|
+
OP.FillColor, 0.5, 0.25, 0.125, 1,
|
|
57
|
+
OP.Arc, 100, 100, 80, 0, 0.5, 0, // dir default = Clockwise
|
|
58
|
+
])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("honors explicit enum options", () => {
|
|
62
|
+
const p = new Painter()
|
|
63
|
+
p.lineCap(Painter.LineCap.Round)
|
|
64
|
+
p.lineJoin(Painter.LineJoin.Bevel)
|
|
65
|
+
p.fill(Painter.FillRule.OddEven)
|
|
66
|
+
p.flush(mgc)
|
|
67
|
+
|
|
68
|
+
expect(Array.from(lastBuffer!)).toEqual([
|
|
69
|
+
OP.LineCap, 1,
|
|
70
|
+
OP.LineJoin, 1,
|
|
71
|
+
OP.Fill, 1,
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("flush is a no-op when nothing was recorded", () => {
|
|
76
|
+
new Painter().flush(mgc)
|
|
77
|
+
expect(executeSpy).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("methods are chainable", () => {
|
|
81
|
+
const p = new Painter()
|
|
82
|
+
const ret = p.beginPath().moveTo(0, 0).lineTo(1, 1)
|
|
83
|
+
expect(ret).toBe(p)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("batchedVisualContent reuses one buffer and does not accumulate across repaints", () => {
|
|
87
|
+
const draw = (p: Painter) => {
|
|
88
|
+
p.beginPath()
|
|
89
|
+
p.moveTo(1, 2)
|
|
90
|
+
}
|
|
91
|
+
const callback = batchedVisualContent(draw)
|
|
92
|
+
|
|
93
|
+
callback(mgc)
|
|
94
|
+
const first = Array.from(lastBuffer!)
|
|
95
|
+
callback(mgc)
|
|
96
|
+
const second = Array.from(lastBuffer!)
|
|
97
|
+
|
|
98
|
+
expect(executeSpy).toHaveBeenCalledTimes(2)
|
|
99
|
+
expect(first).toEqual([OP.BeginPath, OP.MoveTo, 1, 2])
|
|
100
|
+
// Second repaint is identical, not double-length - the painter cleared.
|
|
101
|
+
expect(second).toEqual(first)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -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
|
@@ -75,6 +75,11 @@ declare const CS: {
|
|
|
75
75
|
ApplyStyles: (element: CSObject, styles: Record<string, unknown>) => void;
|
|
76
76
|
AddClassesBatch: (element: CSObject, classes: string[]) => void;
|
|
77
77
|
};
|
|
78
|
+
NodeBridge: {
|
|
79
|
+
Add: (parentHandle: number, childHandle: number) => void;
|
|
80
|
+
Insert: (parentHandle: number, index: number, childHandle: number) => void;
|
|
81
|
+
RemoveFromHierarchy: (childHandle: number) => void;
|
|
82
|
+
};
|
|
78
83
|
};
|
|
79
84
|
};
|
|
80
85
|
|
|
@@ -571,6 +576,35 @@ function untrackParent(child: CSObject) {
|
|
|
571
576
|
}
|
|
572
577
|
}
|
|
573
578
|
|
|
579
|
+
// Tree wiring routes through CS.OneJS.NodeBridge, a zero-alloc fast path, instead
|
|
580
|
+
// of the per-element VisualElement.Add/Insert/RemoveFromHierarchy reflection calls.
|
|
581
|
+
// Handles are read from the proxy's __csHandle (a JS-side field, no crossing).
|
|
582
|
+
// Falls back to the direct proxy call when a handle isn't available (e.g. a
|
|
583
|
+
// container that isn't a handle-tracked element).
|
|
584
|
+
function elementHandle(el: CSObject): number {
|
|
585
|
+
return (el as unknown as { __csHandle?: number }).__csHandle ?? -1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function nodeAdd(parentEl: CSObject, childEl: CSObject) {
|
|
589
|
+
const ph = elementHandle(parentEl);
|
|
590
|
+
const ch = elementHandle(childEl);
|
|
591
|
+
if (ph > 0 && ch > 0) CS.OneJS.NodeBridge.Add(ph, ch);
|
|
592
|
+
else parentEl.Add(childEl);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function nodeInsert(parentEl: CSObject, index: number, childEl: CSObject) {
|
|
596
|
+
const ph = elementHandle(parentEl);
|
|
597
|
+
const ch = elementHandle(childEl);
|
|
598
|
+
if (ph > 0 && ch > 0) CS.OneJS.NodeBridge.Insert(ph, index, ch);
|
|
599
|
+
else parentEl.Insert(index, childEl);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function nodeRemoveFromHierarchy(childEl: CSObject) {
|
|
603
|
+
const ch = elementHandle(childEl);
|
|
604
|
+
if (ch > 0) CS.OneJS.NodeBridge.RemoveFromHierarchy(ch);
|
|
605
|
+
else childEl.RemoveFromHierarchy();
|
|
606
|
+
}
|
|
607
|
+
|
|
574
608
|
|
|
575
609
|
// Apply event handlers
|
|
576
610
|
function applyEvents(instance: Instance, props: BaseProps) {
|
|
@@ -655,7 +689,7 @@ function unmergTextChildren(parentInstance: Instance) {
|
|
|
655
689
|
// Add each merged text child as an actual visual child
|
|
656
690
|
for (const child of children) {
|
|
657
691
|
child.mergedInto = undefined;
|
|
658
|
-
parentInstance.element
|
|
692
|
+
nodeAdd(parentInstance.element, child.element);
|
|
659
693
|
}
|
|
660
694
|
|
|
661
695
|
// Clear the merged children list
|
|
@@ -727,12 +761,12 @@ function insertElementBefore(parentEl: CSObject, childEl: CSObject, beforeChildE
|
|
|
727
761
|
if (beforeIndex < 0) {
|
|
728
762
|
// beforeChild isn't actually in the parent (should not normally happen).
|
|
729
763
|
// Fall back to appending, preserving the previous fallback behavior.
|
|
730
|
-
parentEl
|
|
764
|
+
nodeAdd(parentEl, childEl);
|
|
731
765
|
return;
|
|
732
766
|
}
|
|
733
767
|
const childIndex = parentEl.IndexOf(childEl);
|
|
734
768
|
const target = childIndex >= 0 && childIndex < beforeIndex ? beforeIndex - 1 : beforeIndex;
|
|
735
|
-
parentEl
|
|
769
|
+
nodeInsert(parentEl, target, childEl);
|
|
736
770
|
}
|
|
737
771
|
|
|
738
772
|
// MARK: Component-specific prop handlers
|
|
@@ -1092,7 +1126,7 @@ export const hostConfig = {
|
|
|
1092
1126
|
appendMergedTextChild(parentInstance, child);
|
|
1093
1127
|
} else {
|
|
1094
1128
|
handleNonTextChild(parentInstance);
|
|
1095
|
-
parentInstance.element
|
|
1129
|
+
nodeAdd(parentInstance.element, child.element);
|
|
1096
1130
|
}
|
|
1097
1131
|
trackParent(child.element, parentInstance.element);
|
|
1098
1132
|
},
|
|
@@ -1102,13 +1136,13 @@ export const hostConfig = {
|
|
|
1102
1136
|
appendMergedTextChild(parentInstance, child);
|
|
1103
1137
|
} else {
|
|
1104
1138
|
handleNonTextChild(parentInstance);
|
|
1105
|
-
parentInstance.element
|
|
1139
|
+
nodeAdd(parentInstance.element, child.element);
|
|
1106
1140
|
}
|
|
1107
1141
|
trackParent(child.element, parentInstance.element);
|
|
1108
1142
|
},
|
|
1109
1143
|
|
|
1110
1144
|
appendChildToContainer(container: Container, child: Instance) {
|
|
1111
|
-
container
|
|
1145
|
+
nodeAdd(container, child.element);
|
|
1112
1146
|
// Container is the root - no parent to track
|
|
1113
1147
|
},
|
|
1114
1148
|
|
|
@@ -1132,14 +1166,20 @@ export const hostConfig = {
|
|
|
1132
1166
|
removeMergedTextChild(parentInstance, child);
|
|
1133
1167
|
} else {
|
|
1134
1168
|
__eventAPI.removeAllEventListeners(child.element);
|
|
1135
|
-
|
|
1169
|
+
// RemoveFromHierarchy() detaches the element from its current parent and
|
|
1170
|
+
// is a safe no-op if it's already detached. Using it instead of
|
|
1171
|
+
// parentInstance.element.Remove(child.element) keeps unmount from throwing
|
|
1172
|
+
// when the root was cleared before React tore the tree down (hot reload).
|
|
1173
|
+
nodeRemoveFromHierarchy(child.element);
|
|
1136
1174
|
}
|
|
1137
1175
|
untrackParent(child.element);
|
|
1138
1176
|
},
|
|
1139
1177
|
|
|
1140
1178
|
removeChildFromContainer(container: Container, child: Instance) {
|
|
1141
1179
|
__eventAPI.removeAllEventListeners(child.element);
|
|
1142
|
-
|
|
1180
|
+
// See removeChild: tolerant of an already-detached element so a hot-reload
|
|
1181
|
+
// teardown (which clears the root first) can still unmount cleanly.
|
|
1182
|
+
nodeRemoveFromHierarchy(child.element);
|
|
1143
1183
|
untrackParent(child.element);
|
|
1144
1184
|
},
|
|
1145
1185
|
|
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';
|
|
@@ -48,6 +48,9 @@ export type {
|
|
|
48
48
|
// Vector Drawing
|
|
49
49
|
export { Transform2D, useVectorContent } from './vector';
|
|
50
50
|
|
|
51
|
+
// Batched vector drawing - single-crossing command buffer (see painter.ts)
|
|
52
|
+
export { Painter, batchedVisualContent, useBatchedVectorContent } from './painter';
|
|
53
|
+
|
|
51
54
|
// Sync Hooks & C# Interop Utilities
|
|
52
55
|
export { useFrameSync, useFrameSyncWith, useThrottledSync, useEventSync, toArray } from './hooks';
|
|
53
56
|
export type { EventSource } from './hooks';
|
package/src/painter.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batched vector drawing for OneJS.
|
|
3
|
+
*
|
|
4
|
+
* A Painter records drawing ops into a flat numeric buffer and flushes them to
|
|
5
|
+
* C# in a single crossing via CS.OneJS.PainterBridge.Execute, instead of making
|
|
6
|
+
* one reflection crossing per Painter2D call (and per `new Vector2`/`new Color`)
|
|
7
|
+
* the way raw mgc.painter2D usage does. On the QuickJS interpreter those
|
|
8
|
+
* per-op crossings are the dominant cost of custom vector drawing.
|
|
9
|
+
*
|
|
10
|
+
* For the common case, wrap your draw function with batchedVisualContent so the
|
|
11
|
+
* flush is automatic:
|
|
12
|
+
*
|
|
13
|
+
* import { batchedVisualContent } from "onejs-react"
|
|
14
|
+
*
|
|
15
|
+
* <View
|
|
16
|
+
* style={{ width: 200, height: 200 }}
|
|
17
|
+
* onGenerateVisualContent={batchedVisualContent((p) => {
|
|
18
|
+
* p.fillColor(1, 0, 0, 1)
|
|
19
|
+
* p.beginPath()
|
|
20
|
+
* p.arc(100, 100, 80, 0, Math.PI * 2)
|
|
21
|
+
* p.fill()
|
|
22
|
+
* })}
|
|
23
|
+
* />
|
|
24
|
+
*
|
|
25
|
+
* Gradients, textures, and DrawText are not part of the buffer; for those keep
|
|
26
|
+
* using the raw mgc.painter2D / mgc API.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useRef, useEffect, type DependencyList, type RefObject } from "react"
|
|
30
|
+
import type { VisualElement, MeshGenerationContext, GenerateVisualContentCallback } from "./types"
|
|
31
|
+
|
|
32
|
+
declare const CS: {
|
|
33
|
+
OneJS: {
|
|
34
|
+
PainterBridge: {
|
|
35
|
+
Execute: (mgc: MeshGenerationContext, buffer: Float32Array) => void
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Opcode contract - must match Assets/Singtaa/OneJS/Runtime/PainterBridge.cs.
|
|
41
|
+
const OP_BEGIN_PATH = 1
|
|
42
|
+
const OP_CLOSE_PATH = 2
|
|
43
|
+
const OP_MOVE_TO = 3
|
|
44
|
+
const OP_LINE_TO = 4
|
|
45
|
+
const OP_ARC = 5
|
|
46
|
+
const OP_ARC_TO = 6
|
|
47
|
+
const OP_BEZIER_CURVE_TO = 7
|
|
48
|
+
const OP_QUADRATIC_CURVE_TO = 8
|
|
49
|
+
const OP_FILL = 9
|
|
50
|
+
const OP_STROKE = 10
|
|
51
|
+
const OP_LINE_WIDTH = 11
|
|
52
|
+
const OP_FILL_COLOR = 12
|
|
53
|
+
const OP_STROKE_COLOR = 13
|
|
54
|
+
const OP_LINE_CAP = 14
|
|
55
|
+
const OP_LINE_JOIN = 15
|
|
56
|
+
const OP_MITER_LIMIT = 16
|
|
57
|
+
const OP_DASH_OFFSET = 17
|
|
58
|
+
const OP_DASH_PATTERN = 18
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Records Painter2D drawing ops into a numeric buffer for batched playback.
|
|
62
|
+
*
|
|
63
|
+
* Methods are chainable. Coordinates and colors are plain numbers (no CS object
|
|
64
|
+
* construction), which is the whole point - the buffer crosses to C# once and
|
|
65
|
+
* the structs are built C#-side.
|
|
66
|
+
*
|
|
67
|
+
* Enum-like options live as statics so they do not collide with the CS enum
|
|
68
|
+
* type aliases (ArcDirection, FillRule, ...) re-exported from the package root:
|
|
69
|
+
* p.arc(..., Painter.ArcDirection.CounterClockwise)
|
|
70
|
+
* p.fill(Painter.FillRule.OddEven)
|
|
71
|
+
*/
|
|
72
|
+
export class Painter {
|
|
73
|
+
/** Stroke cap style. Values are the buffer contract, not Unity enum values. */
|
|
74
|
+
static readonly LineCap = { Butt: 0, Round: 1 } as const
|
|
75
|
+
/** Stroke join style. */
|
|
76
|
+
static readonly LineJoin = { Miter: 0, Bevel: 1, Round: 2 } as const
|
|
77
|
+
/** Fill rule for self-intersecting paths. */
|
|
78
|
+
static readonly FillRule = { NonZero: 0, OddEven: 1 } as const
|
|
79
|
+
/** Arc sweep direction. */
|
|
80
|
+
static readonly ArcDirection = { Clockwise: 0, CounterClockwise: 1 } as const
|
|
81
|
+
|
|
82
|
+
private _buf: number[] = []
|
|
83
|
+
|
|
84
|
+
/** Discard all recorded ops, keeping allocated capacity for reuse. */
|
|
85
|
+
clear(): this {
|
|
86
|
+
this._buf.length = 0
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Number of recorded floats. Useful for diagnostics and tests. */
|
|
91
|
+
get length(): number {
|
|
92
|
+
return this._buf.length
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beginPath(): this { this._buf.push(OP_BEGIN_PATH); return this }
|
|
96
|
+
closePath(): this { this._buf.push(OP_CLOSE_PATH); return this }
|
|
97
|
+
moveTo(x: number, y: number): this { this._buf.push(OP_MOVE_TO, x, y); return this }
|
|
98
|
+
lineTo(x: number, y: number): this { this._buf.push(OP_LINE_TO, x, y); return this }
|
|
99
|
+
|
|
100
|
+
/** Arc with center (cx, cy). Angles in radians. dir uses Painter.ArcDirection. */
|
|
101
|
+
arc(cx: number, cy: number, radius: number, startAngle: number, endAngle: number, dir: number = 0 /* Clockwise */): this {
|
|
102
|
+
this._buf.push(OP_ARC, cx, cy, radius, startAngle, endAngle, dir)
|
|
103
|
+
return this
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): this {
|
|
107
|
+
this._buf.push(OP_ARC_TO, x1, y1, x2, y2, radius)
|
|
108
|
+
return this
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): this {
|
|
112
|
+
this._buf.push(OP_BEZIER_CURVE_TO, c1x, c1y, c2x, c2y, x, y)
|
|
113
|
+
return this
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
quadraticCurveTo(cx: number, cy: number, x: number, y: number): this {
|
|
117
|
+
this._buf.push(OP_QUADRATIC_CURVE_TO, cx, cy, x, y)
|
|
118
|
+
return this
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fill the current path. rule uses Painter.FillRule (default NonZero). */
|
|
122
|
+
fill(rule: number = 0 /* NonZero */): this { this._buf.push(OP_FILL, rule); return this }
|
|
123
|
+
stroke(): this { this._buf.push(OP_STROKE); return this }
|
|
124
|
+
|
|
125
|
+
lineWidth(w: number): this { this._buf.push(OP_LINE_WIDTH, w); return this }
|
|
126
|
+
fillColor(r: number, g: number, b: number, a: number = 1): this { this._buf.push(OP_FILL_COLOR, r, g, b, a); return this }
|
|
127
|
+
strokeColor(r: number, g: number, b: number, a: number = 1): this { this._buf.push(OP_STROKE_COLOR, r, g, b, a); return this }
|
|
128
|
+
lineCap(cap: number): this { this._buf.push(OP_LINE_CAP, cap); return this }
|
|
129
|
+
lineJoin(join: number): this { this._buf.push(OP_LINE_JOIN, join); return this }
|
|
130
|
+
miterLimit(limit: number): this { this._buf.push(OP_MITER_LIMIT, limit); return this }
|
|
131
|
+
dashOffset(offset: number): this { this._buf.push(OP_DASH_OFFSET, offset); return this }
|
|
132
|
+
dashPattern(dash: number, gap: number): this { this._buf.push(OP_DASH_PATTERN, dash, gap); return this }
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Send all recorded ops to C# in one crossing, then reset the buffer.
|
|
136
|
+
* No-op when nothing was recorded.
|
|
137
|
+
*/
|
|
138
|
+
flush(mgc: MeshGenerationContext): void {
|
|
139
|
+
if (this._buf.length === 0) return
|
|
140
|
+
CS.OneJS.PainterBridge.Execute(mgc, Float32Array.from(this._buf))
|
|
141
|
+
this._buf.length = 0
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Wrap a draw function so it records into a reused Painter and auto-flushes in
|
|
147
|
+
* one crossing after each repaint. The recommended entry point for batched
|
|
148
|
+
* drawing - assign the result straight to onGenerateVisualContent.
|
|
149
|
+
*/
|
|
150
|
+
export function batchedVisualContent(draw: (p: Painter) => void): GenerateVisualContentCallback {
|
|
151
|
+
const painter = new Painter()
|
|
152
|
+
return (mgc: MeshGenerationContext) => {
|
|
153
|
+
painter.clear()
|
|
154
|
+
draw(painter)
|
|
155
|
+
painter.flush(mgc)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Batched counterpart to useVectorContent. Returns a ref to attach to a
|
|
161
|
+
* VisualElement; the draw callback records into a reused Painter that flushes in
|
|
162
|
+
* a single crossing. Repaints automatically when deps change.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* const ref = useBatchedVectorContent((p) => {
|
|
166
|
+
* p.fillColor(1, 0, 0, 1)
|
|
167
|
+
* p.beginPath()
|
|
168
|
+
* p.arc(100, 100, radius, 0, Math.PI * 2)
|
|
169
|
+
* p.fill()
|
|
170
|
+
* }, [radius])
|
|
171
|
+
* return <View ref={ref} style={{ width: 200, height: 200 }} />
|
|
172
|
+
*/
|
|
173
|
+
export function useBatchedVectorContent(
|
|
174
|
+
draw: (p: Painter) => void,
|
|
175
|
+
deps: DependencyList = []
|
|
176
|
+
): RefObject<VisualElement | null> {
|
|
177
|
+
const ref = useRef<VisualElement | null>(null)
|
|
178
|
+
const drawRef = useRef(draw)
|
|
179
|
+
drawRef.current = draw
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const element = ref.current
|
|
183
|
+
if (!element) return
|
|
184
|
+
|
|
185
|
+
const painter = new Painter()
|
|
186
|
+
const callback: GenerateVisualContentCallback = (mgc) => {
|
|
187
|
+
painter.clear()
|
|
188
|
+
drawRef.current(painter)
|
|
189
|
+
painter.flush(mgc)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const el = element as unknown as { generateVisualContent: GenerateVisualContentCallback | null }
|
|
193
|
+
el.generateVisualContent = callback
|
|
194
|
+
element.MarkDirtyRepaint()
|
|
195
|
+
|
|
196
|
+
return () => {
|
|
197
|
+
el.generateVisualContent = null
|
|
198
|
+
}
|
|
199
|
+
}, [])
|
|
200
|
+
|
|
201
|
+
const isFirstRender = useRef(true)
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (isFirstRender.current) {
|
|
204
|
+
isFirstRender.current = false
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
const element = ref.current
|
|
208
|
+
if (element) element.MarkDirtyRepaint()
|
|
209
|
+
}, deps)
|
|
210
|
+
|
|
211
|
+
return ref
|
|
212
|
+
}
|
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;
|