@ripple-ts/compat-react 0.2.180 → 0.2.183

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": "@ripple-ts/compat-react",
3
- "version": "0.2.180",
3
+ "version": "0.2.183",
4
4
  "description": "Ripple compatibility layer for React",
5
5
  "main": "src/index.js",
6
6
  "author": "Dominic Gannaway",
@@ -17,7 +17,7 @@
17
17
  "dependencies": {
18
18
  "react": "^19.2.0",
19
19
  "react-dom": "^19.2.0",
20
- "ripple": "0.2.180"
20
+ "ripple": "0.2.183"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/react": "^19.2.2",
@@ -1,5 +1,6 @@
1
1
  import { track, flushSync } from 'ripple';
2
- import { act, createContext, useContext } from 'react';
2
+ import { act, createContext, useContext, Suspense } from 'react';
3
+ import { vi } from 'vitest';
3
4
  import { Ripple } from '@ripple-ts/compat-react';
4
5
  import { createRoot } from 'react-dom/client';
5
6
  import { jsx, jsxs } from 'react/jsx-runtime';
@@ -387,7 +388,7 @@ describe('compat-react', () => {
387
388
 
388
389
  component App() {
389
390
  <tsx:react>
390
- <DemoContext.Provider value={"Hello from Context!"}>
391
+ <DemoContext.Provider value={'Hello from Context!'}>
391
392
  <ReactChild />
392
393
  </DemoContext.Provider>
393
394
  </tsx:react>
@@ -401,6 +402,285 @@ describe('compat-react', () => {
401
402
  expect(deepChild).toBeTruthy();
402
403
  expect(deepChild.textContent).toBe('Deep child, context value is: Hello from Context!');
403
404
  });
405
+
406
+ it('should handle React Suspense with Ripple await logic', async () => {
407
+ vi.useFakeTimers();
408
+
409
+ try {
410
+ function sleep(ms: number) {
411
+ return new Promise((resolve) => setTimeout(resolve, ms));
412
+ }
413
+
414
+ component AsyncRippleChild() {
415
+ await sleep(1000);
416
+ <div class="async-content">{'Loaded!'}</div>
417
+ }
418
+
419
+ function ReactChild() {
420
+ return jsx(Ripple, { component: AsyncRippleChild });
421
+ }
422
+
423
+ component App() {
424
+ <tsx:react>
425
+ <Suspense fallback={<div className="loading">Loading...</div>}>
426
+ <ReactChild />
427
+ </Suspense>
428
+ </tsx:react>
429
+ }
430
+
431
+ await act(async () => {
432
+ render(App);
433
+ });
434
+
435
+ // Initially shows loading state
436
+ const loading = container.querySelector('.loading');
437
+ expect(loading).toBeTruthy();
438
+ expect(loading.textContent).toBe('Loading...');
439
+ expect(container.querySelector('.async-content')).toBeFalsy();
440
+
441
+ // Advance timers to resolve the async operation
442
+ await act(async () => {
443
+ await vi.advanceTimersByTimeAsync(1000);
444
+ });
445
+
446
+ // Now shows loaded content
447
+ const content = container.querySelector('.async-content');
448
+ expect(content).toBeTruthy();
449
+ expect(content.textContent).toBe('Loaded!');
450
+ expect(container.querySelector('.loading')).toBeFalsy();
451
+ } finally {
452
+ vi.useRealTimers();
453
+ }
454
+ });
455
+
456
+ it('should handle Ripple reactivity updates after Suspense resolves', async () => {
457
+ vi.useFakeTimers();
458
+
459
+ try {
460
+ function sleep(ms: number) {
461
+ return new Promise((resolve) => setTimeout(resolve, ms));
462
+ }
463
+
464
+ component AsyncRippleChild() {
465
+ await sleep(1000);
466
+
467
+ let count = track(0);
468
+
469
+ <div>
470
+ <div class="async-content">{'Loaded!'}</div>
471
+ <div class="count">{@count}</div>
472
+ <button onClick={() => @count++}>{'Increment'}</button>
473
+ </div>
474
+ }
475
+
476
+ function ReactChild() {
477
+ return jsx(Ripple, { component: AsyncRippleChild });
478
+ }
479
+
480
+ component App() {
481
+ <tsx:react>
482
+ <Suspense fallback={<div className="loading">Loading...</div>}>
483
+ <ReactChild />
484
+ </Suspense>
485
+ </tsx:react>
486
+ }
487
+
488
+ await act(async () => {
489
+ render(App);
490
+ });
491
+
492
+ // Initially shows loading state
493
+ expect(container.querySelector('.loading')).toBeTruthy();
494
+ expect(container.querySelector('.async-content')).toBeFalsy();
495
+
496
+ // Advance timers to resolve the async operation
497
+ await act(async () => {
498
+ await vi.advanceTimersByTimeAsync(1000);
499
+ });
500
+
501
+ // Now shows loaded content with initial count
502
+ expect(container.querySelector('.async-content')).toBeTruthy();
503
+ expect(container.querySelector('.count').textContent).toBe('0');
504
+
505
+ // Click button to increment count
506
+ const button = container.querySelector('button');
507
+ button.click();
508
+ flushSync();
509
+
510
+ // Count should be updated reactively
511
+ expect(container.querySelector('.count').textContent).toBe('1');
512
+
513
+ // Click again
514
+ button.click();
515
+ flushSync();
516
+
517
+ expect(container.querySelector('.count').textContent).toBe('2');
518
+ } finally {
519
+ vi.useRealTimers();
520
+ }
521
+ });
522
+
523
+ it('should handle tracked async triggering re-suspension on state change', async () => {
524
+ vi.useFakeTimers();
525
+
526
+ try {
527
+ function sleep(ms: number) {
528
+ return new Promise((resolve) => setTimeout(resolve, ms));
529
+ }
530
+
531
+ let setPage: (page: number) => void;
532
+
533
+ component AsyncRippleChild({ page }: { page: number }) {
534
+ // Using await track(() => ...) to create a reactive async operation
535
+ // that re-suspends when the tracked dependency (page) changes
536
+ await track(() => {
537
+ page;
538
+ return sleep(1000);
539
+ });
540
+
541
+ <div class="content">{`Page ${page} loaded`}</div>
542
+ }
543
+
544
+ function ReactChild(props: { page: number }) {
545
+ return jsx(Ripple, {
546
+ component: AsyncRippleChild,
547
+ props: { page: props.page },
548
+ });
549
+ }
550
+
551
+ component App() {
552
+ let page = track(1);
553
+ setPage = (p: number) => {
554
+ @page = p;
555
+ };
556
+
557
+ <tsx:react>
558
+ <Suspense fallback={<div className="loading">Loading...</div>}>
559
+ <ReactChild page={@page} />
560
+ </Suspense>
561
+ </tsx:react>
562
+ }
563
+
564
+ await act(async () => {
565
+ render(App);
566
+ });
567
+
568
+ // Initially shows loading state
569
+ expect(container.querySelector('.loading')).toBeTruthy();
570
+ expect(container.querySelector('.content')).toBeFalsy();
571
+
572
+ // Advance timers to resolve the first async operation
573
+ await act(async () => {
574
+ await vi.advanceTimersByTimeAsync(1000);
575
+ });
576
+
577
+ // Now shows page 1 content
578
+ expect(container.querySelector('.content')).toBeTruthy();
579
+ expect(container.querySelector('.content').textContent).toBe('Page 1 loaded');
580
+ expect(container.querySelector('.loading')).toBeFalsy();
581
+
582
+ // Change page to trigger re-suspension via tracked dependency
583
+ await act(async () => {
584
+ setPage(2);
585
+ flushSync();
586
+ });
587
+
588
+ // Should show loading again due to reactive async re-run
589
+ expect(container.querySelector('.loading')).toBeTruthy();
590
+
591
+ // Advance timers to resolve the second async operation
592
+ await act(async () => {
593
+ await vi.advanceTimersByTimeAsync(1000);
594
+ });
595
+
596
+ // Now shows page 2 content
597
+ expect(container.querySelector('.content')).toBeTruthy();
598
+ expect(container.querySelector('.content').textContent).toBe('Page 2 loaded');
599
+ expect(container.querySelector('.loading')).toBeFalsy();
600
+ } finally {
601
+ vi.useRealTimers();
602
+ }
603
+ });
604
+
605
+ it('should handle re-suspension via React key prop forcing remount', async () => {
606
+ vi.useFakeTimers();
607
+
608
+ try {
609
+ function sleep(ms: number) {
610
+ return new Promise((resolve) => setTimeout(resolve, ms));
611
+ }
612
+
613
+ let setPage: (page: number) => void;
614
+
615
+ component AsyncRippleChild({ page }: { page: number }) {
616
+ // Simple static await - no tracked dependencies
617
+ await sleep(1000);
618
+
619
+ <div class="content">{`Page ${page} loaded`}</div>
620
+ }
621
+
622
+ function ReactChild(props: { page: number }) {
623
+ // Use key to force remount when page changes
624
+ return jsx(Ripple, {
625
+ key: props.page,
626
+ component: AsyncRippleChild,
627
+ props: { page: props.page },
628
+ });
629
+ }
630
+
631
+ component App() {
632
+ let page = track(1);
633
+ setPage = (p: number) => {
634
+ @page = p;
635
+ };
636
+
637
+ <tsx:react>
638
+ <Suspense fallback={<div className="loading">Loading...</div>}>
639
+ <ReactChild page={@page} />
640
+ </Suspense>
641
+ </tsx:react>
642
+ }
643
+
644
+ await act(async () => {
645
+ render(App);
646
+ });
647
+
648
+ // Initially shows loading state
649
+ expect(container.querySelector('.loading')).toBeTruthy();
650
+ expect(container.querySelector('.content')).toBeFalsy();
651
+
652
+ // Advance timers to resolve the first async operation
653
+ await act(async () => {
654
+ await vi.advanceTimersByTimeAsync(1000);
655
+ });
656
+
657
+ // Now shows page 1 content
658
+ expect(container.querySelector('.content')).toBeTruthy();
659
+ expect(container.querySelector('.content').textContent).toBe('Page 1 loaded');
660
+ expect(container.querySelector('.loading')).toBeFalsy();
661
+
662
+ // Change page to trigger re-suspension via key-based remount
663
+ await act(async () => {
664
+ setPage(2);
665
+ flushSync();
666
+ });
667
+
668
+ // Should show loading again due to component remount
669
+ expect(container.querySelector('.loading')).toBeTruthy();
670
+
671
+ // Advance timers to resolve the second async operation
672
+ await act(async () => {
673
+ await vi.advanceTimersByTimeAsync(1000);
674
+ });
675
+
676
+ // Now shows page 2 content
677
+ expect(container.querySelector('.content')).toBeTruthy();
678
+ expect(container.querySelector('.content').textContent).toBe('Page 2 loaded');
679
+ expect(container.querySelector('.loading')).toBeFalsy();
680
+ } finally {
681
+ vi.useRealTimers();
682
+ }
683
+ });
404
684
  });
405
685
 
406
686
  describe('Ripple in React app', () => {