onejs-react 0.1.22 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-react",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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', {});
@@ -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
  });
@@ -677,6 +677,32 @@ function removeMergedTextChild(parentInstance: Instance, child: Instance) {
677
677
  rebuildMergedText(parentInstance);
678
678
  }
679
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
+
680
706
  // MARK: Component-specific prop handlers
681
707
 
682
708
  // Apply common props (text, value, label) - skip unchanged values
@@ -1046,23 +1072,13 @@ export const hostConfig = {
1046
1072
  insertMergedTextChild(parentInstance, child, beforeChild);
1047
1073
  } else {
1048
1074
  handleNonTextChild(parentInstance);
1049
- const index = parentInstance.element.IndexOf(beforeChild.element);
1050
- if (index >= 0) {
1051
- parentInstance.element.Insert(index, child.element);
1052
- } else {
1053
- parentInstance.element.Add(child.element);
1054
- }
1075
+ insertElementBefore(parentInstance.element, child.element, beforeChild.element);
1055
1076
  }
1056
1077
  trackParent(child.element, parentInstance.element);
1057
1078
  },
1058
1079
 
1059
1080
  insertInContainerBefore(container: Container, child: Instance, beforeChild: Instance) {
1060
- const index = container.IndexOf(beforeChild.element);
1061
- if (index >= 0) {
1062
- container.Insert(index, child.element);
1063
- } else {
1064
- container.Add(child.element);
1065
- }
1081
+ insertElementBefore(container, child.element, beforeChild.element);
1066
1082
  // Container is the root - no parent to track
1067
1083
  },
1068
1084