onejs-react 0.1.21 → 0.1.23
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/package.json +1 -1
- package/src/__tests__/host-config.test.ts +41 -0
- package/src/__tests__/mocks.ts +16 -0
- package/src/__tests__/renderer.test.tsx +67 -0
- package/src/host-config.ts +77 -26
package/package.json
CHANGED
|
@@ -644,6 +644,47 @@ describe('host-config', () => {
|
|
|
644
644
|
expect(parentEl.children[2]).toBe(child3.element);
|
|
645
645
|
});
|
|
646
646
|
|
|
647
|
+
it('insertBefore moving an existing child before a trailing sibling does not overshoot', () => {
|
|
648
|
+
// Reproduces the keyed-array-next-to-static-sibling reorder bug.
|
|
649
|
+
// React reorders [a, b, static] -> [b, a, static] by giving the reused
|
|
650
|
+
// child `a` a Placement and committing insertBefore(parent, a, static).
|
|
651
|
+
// Unity's Insert(i, child) removes the child *before* placing it at i, so a
|
|
652
|
+
// naive IndexOf(static) + Insert overshoots and lands `a` past `static`.
|
|
653
|
+
const parent = createInstance('ojs-view', {});
|
|
654
|
+
const a = createInstance('ojs-label', { text: 'a' });
|
|
655
|
+
const b = createInstance('ojs-label', { text: 'b' });
|
|
656
|
+
const staticSibling = createInstance('ojs-button', { text: 'STATIC' });
|
|
657
|
+
|
|
658
|
+
appendChild(parent, a);
|
|
659
|
+
appendChild(parent, b);
|
|
660
|
+
appendChild(parent, staticSibling);
|
|
661
|
+
|
|
662
|
+
// React moves `a` to sit right before the (unchanged) static sibling.
|
|
663
|
+
insertBefore(parent, a, staticSibling);
|
|
664
|
+
|
|
665
|
+
const parentEl = getMockElement(parent);
|
|
666
|
+
expect(parentEl.children.map((c) => c.text)).toEqual(['b', 'a', 'STATIC']);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('insertBefore moving an existing child after another keeps order', () => {
|
|
670
|
+
// The mirror case: child currently sits *after* beforeChild. Removal does
|
|
671
|
+
// not shift beforeChild, so the index must not be decremented.
|
|
672
|
+
const parent = createInstance('ojs-view', {});
|
|
673
|
+
const a = createInstance('ojs-label', { text: 'a' });
|
|
674
|
+
const b = createInstance('ojs-label', { text: 'b' });
|
|
675
|
+
const c = createInstance('ojs-label', { text: 'c' });
|
|
676
|
+
|
|
677
|
+
appendChild(parent, a);
|
|
678
|
+
appendChild(parent, b);
|
|
679
|
+
appendChild(parent, c);
|
|
680
|
+
|
|
681
|
+
// Move `c` to sit right before `b`: [a, b, c] -> [a, c, b].
|
|
682
|
+
insertBefore(parent, c, b);
|
|
683
|
+
|
|
684
|
+
const parentEl = getMockElement(parent);
|
|
685
|
+
expect(parentEl.children.map((el) => el.text)).toEqual(['a', 'c', 'b']);
|
|
686
|
+
});
|
|
687
|
+
|
|
647
688
|
it('removeChild removes child from parent', () => {
|
|
648
689
|
const parent = createInstance('ojs-view', {});
|
|
649
690
|
const child = createInstance('ojs-label', {});
|
package/src/__tests__/mocks.ts
CHANGED
|
@@ -338,6 +338,22 @@ export function createMockCS() {
|
|
|
338
338
|
ClearElementBackgroundImage: () => {},
|
|
339
339
|
},
|
|
340
340
|
},
|
|
341
|
+
// Mirrors the real CS.OneJS.StyleBridge batched path: ApplyStyles writes
|
|
342
|
+
// each parsed style value onto element.style; AddClassesBatch adds each
|
|
343
|
+
// class. host-config sends pre-parsed values (MockLength/MockColor/etc.),
|
|
344
|
+
// so a direct assignment is faithful for assertions.
|
|
345
|
+
StyleBridge: {
|
|
346
|
+
ApplyStyles: (element: MockVisualElement, styles: Record<string, unknown>) => {
|
|
347
|
+
for (const key in styles) {
|
|
348
|
+
element.style[key] = styles[key];
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
AddClassesBatch: (element: MockVisualElement, classes: string[]) => {
|
|
352
|
+
for (const cls of classes) {
|
|
353
|
+
element.AddToClassList(cls);
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
},
|
|
341
357
|
},
|
|
342
358
|
};
|
|
343
359
|
}
|
|
@@ -383,5 +383,72 @@ describe('renderer', () => {
|
|
|
383
383
|
expect(view.childCount).toBe(3);
|
|
384
384
|
expect((view.children[2] as MockVisualElement).text).toBe('C');
|
|
385
385
|
});
|
|
386
|
+
|
|
387
|
+
it('keeps a trailing static sibling last when a keyed list reorders', async () => {
|
|
388
|
+
// Regression: a keyed array followed by a static sibling, where reordering
|
|
389
|
+
// the array moves a reused child. React commits that move as
|
|
390
|
+
// insertBefore(view, movedChild, staticButton); the child must land before
|
|
391
|
+
// the button, not overshoot past it.
|
|
392
|
+
const container = createMockContainer();
|
|
393
|
+
let setOrder: (order: string[]) => void;
|
|
394
|
+
|
|
395
|
+
function Palette() {
|
|
396
|
+
const [order, _setOrder] = useState(['a', 'b']);
|
|
397
|
+
setOrder = _setOrder;
|
|
398
|
+
return (
|
|
399
|
+
<ojs-view>
|
|
400
|
+
{order.map((id) => (
|
|
401
|
+
<ojs-label key={id} text={id} />
|
|
402
|
+
))}
|
|
403
|
+
<ojs-button text="ADD" />
|
|
404
|
+
</ojs-view>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
render(<Palette />, container as any);
|
|
409
|
+
await flushMicrotasks();
|
|
410
|
+
|
|
411
|
+
const view = container.children[0] as MockVisualElement;
|
|
412
|
+
expect(view.children.map((c) => c.text)).toEqual(['a', 'b', 'ADD']);
|
|
413
|
+
|
|
414
|
+
// Swap the two keyed children; the static ADD button must stay last.
|
|
415
|
+
setOrder!(['b', 'a']);
|
|
416
|
+
await flushMicrotasks();
|
|
417
|
+
|
|
418
|
+
expect(view.children.map((c) => c.text)).toEqual(['b', 'a', 'ADD']);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('keeps a trailing static sibling last across remove-then-add reorders', async () => {
|
|
422
|
+
// Closer to the reported scenario: blocks are removed and added such that a
|
|
423
|
+
// surviving block reorders relative to its old position. The trailing
|
|
424
|
+
// button must remain the last child throughout.
|
|
425
|
+
const container = createMockContainer();
|
|
426
|
+
let setIds: (ids: string[]) => void;
|
|
427
|
+
|
|
428
|
+
function Palette() {
|
|
429
|
+
const [ids, _setIds] = useState(['x', 'y', 'z']);
|
|
430
|
+
setIds = _setIds;
|
|
431
|
+
return (
|
|
432
|
+
<ojs-view>
|
|
433
|
+
{ids.map((id) => (
|
|
434
|
+
<ojs-label key={id} text={id} />
|
|
435
|
+
))}
|
|
436
|
+
<ojs-button text="ADD" />
|
|
437
|
+
</ojs-view>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
render(<Palette />, container as any);
|
|
442
|
+
await flushMicrotasks();
|
|
443
|
+
|
|
444
|
+
const view = container.children[0] as MockVisualElement;
|
|
445
|
+
expect(view.children.map((c) => c.text)).toEqual(['x', 'y', 'z', 'ADD']);
|
|
446
|
+
|
|
447
|
+
// Remove 'x' and reintroduce a block such that survivors reorder.
|
|
448
|
+
setIds!(['z', 'y', 'w']);
|
|
449
|
+
await flushMicrotasks();
|
|
450
|
+
|
|
451
|
+
expect(view.children.map((c) => c.text)).toEqual(['z', 'y', 'w', 'ADD']);
|
|
452
|
+
});
|
|
386
453
|
});
|
|
387
454
|
});
|
package/src/host-config.ts
CHANGED
|
@@ -71,6 +71,10 @@ declare const CS: {
|
|
|
71
71
|
};
|
|
72
72
|
FrostedGlassElement: new () => CSObject;
|
|
73
73
|
};
|
|
74
|
+
StyleBridge: {
|
|
75
|
+
ApplyStyles: (element: CSObject, styles: Record<string, unknown>) => void;
|
|
76
|
+
AddClassesBatch: (element: CSObject, classes: string[]) => void;
|
|
77
|
+
};
|
|
74
78
|
};
|
|
75
79
|
};
|
|
76
80
|
|
|
@@ -385,47 +389,73 @@ function getRenderTextureHandle(value: RenderTextureRef): number {
|
|
|
385
389
|
return value.__rtHandle ?? value.__handle ?? -1;
|
|
386
390
|
}
|
|
387
391
|
|
|
388
|
-
// Apply style properties to element, returns the set of applied keys
|
|
392
|
+
// Apply style properties to element, returns the set of applied keys.
|
|
393
|
+
//
|
|
394
|
+
// Batched path: parsed style values are collected into a single dict and sent
|
|
395
|
+
// to CS.OneJS.StyleBridge.ApplyStyles in one __cs.invoke crossing instead of
|
|
396
|
+
// one per property. On WebGL each crossing is ~3ms (JSON marshal + reflection),
|
|
397
|
+
// so the difference is ~N invokes vs 1 invoke per element. backgroundImage
|
|
398
|
+
// stays on its individual GPU-bridge path since it's not a plain IStyle setter.
|
|
389
399
|
function applyStyle(element: CSObject, style: ViewStyle | undefined): Set<string> {
|
|
390
400
|
const appliedKeys = new Set<string>();
|
|
391
401
|
if (!style) return appliedKeys;
|
|
392
402
|
|
|
393
|
-
const
|
|
403
|
+
const batched: Record<string, unknown> = {}
|
|
404
|
+
|
|
394
405
|
for (const [key, value] of Object.entries(style)) {
|
|
395
406
|
if (value === undefined) continue;
|
|
396
407
|
|
|
397
|
-
// Handle shorthand properties
|
|
398
408
|
const expanded = STYLE_SHORTHANDS[key];
|
|
399
409
|
if (expanded) {
|
|
400
|
-
|
|
401
|
-
const parsed = parseStyleValue(expanded[0], value);
|
|
410
|
+
const parsed = resolveForBatch(parseStyleValue(expanded[0], value));
|
|
402
411
|
for (const prop of expanded) {
|
|
403
|
-
|
|
412
|
+
batched[prop] = parsed;
|
|
404
413
|
appliedKeys.add(prop);
|
|
405
414
|
}
|
|
406
415
|
} else if (key === "backgroundImage") {
|
|
407
416
|
if (value == null) {
|
|
408
417
|
CS.OneJS.GPU.GPUBridge.ClearElementBackgroundImage(element);
|
|
409
418
|
} else if (isRenderTextureHandle(value)) {
|
|
410
|
-
// GPU compute RenderTexture handles (via rt.getUnityObject())
|
|
411
419
|
const handle = getRenderTextureHandle(value);
|
|
412
420
|
if (handle >= 0) {
|
|
413
421
|
CS.OneJS.GPU.GPUBridge.SetElementBackgroundImage(element, handle);
|
|
414
422
|
}
|
|
415
423
|
} else if (typeof value === "object" && "__csHandle" in value) {
|
|
416
|
-
// C# objects: Texture2D, Sprite, VectorImage, RenderTexture
|
|
417
424
|
CS.OneJS.GPU.GPUBridge.SetElementBackgroundFromObject(element, value);
|
|
418
425
|
}
|
|
419
426
|
appliedKeys.add(key);
|
|
420
427
|
} else {
|
|
421
|
-
|
|
422
|
-
s[key] = parseStyleValue(key, value);
|
|
428
|
+
batched[key] = resolveForBatch(parseStyleValue(key, value));
|
|
423
429
|
appliedKeys.add(key);
|
|
424
430
|
}
|
|
425
431
|
}
|
|
432
|
+
|
|
433
|
+
if (Object.keys(batched).length > 0) {
|
|
434
|
+
CS.OneJS.StyleBridge.ApplyStyles(element, batched);
|
|
435
|
+
}
|
|
436
|
+
|
|
426
437
|
return appliedKeys;
|
|
427
438
|
}
|
|
428
439
|
|
|
440
|
+
// Force-resolve CS path proxies (e.g. CS.UnityEngine.UIElements.Justify.Center)
|
|
441
|
+
// to their underlying int value. parseEnumValue and parseLength's StyleKeyword
|
|
442
|
+
// cases return path proxies whose .valueOf() reads the int via GetField. The
|
|
443
|
+
// non-batched __cs.invoke path resolved these implicitly via __resolveValue;
|
|
444
|
+
// the batched path JSON.stringifies the whole dict, so path proxies serialize
|
|
445
|
+
// via toJSON to {__csTypeRef:...} which C# can't interpret as an enum value.
|
|
446
|
+
// CS object proxies (with __csHandle) keep their toJSON shape — only path
|
|
447
|
+
// proxies need coercion.
|
|
448
|
+
function resolveForBatch(value: unknown): unknown {
|
|
449
|
+
// Path proxies (e.g. CS.UnityEngine.UIElements.Justify.Center) have a
|
|
450
|
+
// function as their underlying Proxy target so they can also be invoked
|
|
451
|
+
// as constructors — so typeof is "function", not "object". Just check the
|
|
452
|
+
// __csPathProxy sentinel and rely on truthy/property semantics.
|
|
453
|
+
if (value && (value as any).__csPathProxy) {
|
|
454
|
+
return Number(value)
|
|
455
|
+
}
|
|
456
|
+
return value
|
|
457
|
+
}
|
|
458
|
+
|
|
429
459
|
// Clear style properties that are no longer in the new style
|
|
430
460
|
function clearRemovedStyles(element: CSObject, oldKeys: Set<string>, newKeys: Set<string>) {
|
|
431
461
|
const s = element.style;
|
|
@@ -458,13 +488,18 @@ function parseClassNames(className: string | undefined): Set<string> {
|
|
|
458
488
|
return result;
|
|
459
489
|
}
|
|
460
490
|
|
|
461
|
-
// Apply className(s) to element (with escaping for Tailwind/USS compatibility)
|
|
491
|
+
// Apply className(s) to element (with escaping for Tailwind/USS compatibility).
|
|
492
|
+
// Routes through StyleBridge.AddClassesBatch so a multi-class string is one
|
|
493
|
+
// __cs.invoke crossing instead of one per class.
|
|
462
494
|
function applyClassName(element: CSObject, className: string | undefined) {
|
|
463
495
|
if (!className) return;
|
|
464
496
|
|
|
465
|
-
const classes =
|
|
466
|
-
for (const cls of
|
|
467
|
-
|
|
497
|
+
const classes: string[] = [];
|
|
498
|
+
for (const cls of className.split(/\s+/)) {
|
|
499
|
+
if (cls) classes.push(escapeClassName(cls));
|
|
500
|
+
}
|
|
501
|
+
if (classes.length > 0) {
|
|
502
|
+
CS.OneJS.StyleBridge.AddClassesBatch(element, classes);
|
|
468
503
|
}
|
|
469
504
|
}
|
|
470
505
|
|
|
@@ -642,6 +677,32 @@ function removeMergedTextChild(parentInstance: Instance, child: Instance) {
|
|
|
642
677
|
rebuildMergedText(parentInstance);
|
|
643
678
|
}
|
|
644
679
|
|
|
680
|
+
// Insert childEl immediately before beforeChildEl in parentEl's child list.
|
|
681
|
+
//
|
|
682
|
+
// Unity's VisualElement.Insert(i, child) calls child.RemoveFromHierarchy() *before*
|
|
683
|
+
// placing it at index i. So when childEl is already a child of parentEl sitting
|
|
684
|
+
// *before* beforeChildEl (a reorder/move, which is exactly what react-reconciler
|
|
685
|
+
// commits via insertBefore for a reused keyed child), that internal removal shifts
|
|
686
|
+
// beforeChildEl down one slot and childEl overshoots its target, landing one
|
|
687
|
+
// position too late (e.g. past a static sibling kept last). Target one slot earlier
|
|
688
|
+
// in precisely that case. Fresh inserts (childEl not yet in parentEl) and children
|
|
689
|
+
// sitting after beforeChildEl are unaffected.
|
|
690
|
+
//
|
|
691
|
+
// IndexOf/Insert/Add all route through contentContainer identically, so the indices
|
|
692
|
+
// and the insertion share one coordinate space (correct for ScrollView-style parents).
|
|
693
|
+
function insertElementBefore(parentEl: CSObject, childEl: CSObject, beforeChildEl: CSObject) {
|
|
694
|
+
const beforeIndex = parentEl.IndexOf(beforeChildEl);
|
|
695
|
+
if (beforeIndex < 0) {
|
|
696
|
+
// beforeChild isn't actually in the parent (should not normally happen).
|
|
697
|
+
// Fall back to appending, preserving the previous fallback behavior.
|
|
698
|
+
parentEl.Add(childEl);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const childIndex = parentEl.IndexOf(childEl);
|
|
702
|
+
const target = childIndex >= 0 && childIndex < beforeIndex ? beforeIndex - 1 : beforeIndex;
|
|
703
|
+
parentEl.Insert(target, childEl);
|
|
704
|
+
}
|
|
705
|
+
|
|
645
706
|
// MARK: Component-specific prop handlers
|
|
646
707
|
|
|
647
708
|
// Apply common props (text, value, label) - skip unchanged values
|
|
@@ -1011,23 +1072,13 @@ export const hostConfig = {
|
|
|
1011
1072
|
insertMergedTextChild(parentInstance, child, beforeChild);
|
|
1012
1073
|
} else {
|
|
1013
1074
|
handleNonTextChild(parentInstance);
|
|
1014
|
-
|
|
1015
|
-
if (index >= 0) {
|
|
1016
|
-
parentInstance.element.Insert(index, child.element);
|
|
1017
|
-
} else {
|
|
1018
|
-
parentInstance.element.Add(child.element);
|
|
1019
|
-
}
|
|
1075
|
+
insertElementBefore(parentInstance.element, child.element, beforeChild.element);
|
|
1020
1076
|
}
|
|
1021
1077
|
trackParent(child.element, parentInstance.element);
|
|
1022
1078
|
},
|
|
1023
1079
|
|
|
1024
1080
|
insertInContainerBefore(container: Container, child: Instance, beforeChild: Instance) {
|
|
1025
|
-
|
|
1026
|
-
if (index >= 0) {
|
|
1027
|
-
container.Insert(index, child.element);
|
|
1028
|
-
} else {
|
|
1029
|
-
container.Add(child.element);
|
|
1030
|
-
}
|
|
1081
|
+
insertElementBefore(container, child.element, beforeChild.element);
|
|
1031
1082
|
// Container is the root - no parent to track
|
|
1032
1083
|
},
|
|
1033
1084
|
|