onejs-react 0.1.29 → 0.1.30

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.29",
3
+ "version": "0.1.30",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -22,7 +22,7 @@ import {
22
22
  Image,
23
23
  clearImageCache,
24
24
  } from '../components';
25
- import { MockVisualElement, MockTexture2D, MockLength, MockColor, createMockContainer, flushMicrotasks, getEventAPI, mockFileSystem } from './mocks';
25
+ import { MockVisualElement, MockTexture2D, MockVectorImage, MockLength, MockColor, createMockContainer, flushMicrotasks, getEventAPI, mockFileSystem, mockUrlAssets } from './mocks';
26
26
 
27
27
  // Helper to extract value from style (handles both raw values and MockLength/MockColor)
28
28
  function getStyleValue(style: unknown): unknown {
@@ -443,6 +443,98 @@ describe('components', () => {
443
443
 
444
444
  expect(readSpy).toHaveBeenCalledTimes(2);
445
445
  });
446
+
447
+ it('loads SVG synchronously via SVGUtils in the editor', async () => {
448
+ mockFileSystem.set("/project/App/assets/icons/star.svg", "<svg></svg>");
449
+ (globalThis as any).__workingDir = "/project/App";
450
+
451
+ const container = createMockContainer();
452
+ render(<Image src="icons/star.svg" />, container as any);
453
+ await flushMicrotasks();
454
+
455
+ const el = container.children[0] as any;
456
+ expect(el.image).toBeInstanceOf(MockVectorImage);
457
+ expect((el.image as MockVectorImage).svgText).toBe("<svg></svg>");
458
+ });
459
+ });
460
+
461
+ describe('Image on URL StreamingAssets platforms (Android APK, WebGL)', () => {
462
+ const JAR_BASE = "jar:file:///data/app/base.apk!/assets";
463
+
464
+ // Simulate an Android player: not the editor, and streamingAssetsPath
465
+ // is a jar: URL inside the APK that System.IO.File cannot read
466
+ beforeEach(() => {
467
+ const app = (globalThis as any).CS.UnityEngine.Application;
468
+ app.isEditor = false;
469
+ app.streamingAssetsPath = JAR_BASE;
470
+ });
471
+
472
+ it('loads raster images asynchronously via Network.LoadTextureFromUrl', async () => {
473
+ mockUrlAssets.set(`${JAR_BASE}/onejs/assets/images/logo.png`, [0x89, 0x50, 0x4e, 0x47]);
474
+
475
+ const container = createMockContainer();
476
+ render(<Image src="images/logo.png" />, container as any);
477
+ await flushMicrotasks();
478
+
479
+ const el = container.children[0] as any;
480
+ expect(el.image).toBeInstanceOf(MockTexture2D);
481
+ expect((el.image as MockTexture2D)._loaded).toBe(true);
482
+ expect((el.image as MockTexture2D).filterMode).toBe(1); // Bilinear
483
+ });
484
+
485
+ it('loads SVG asynchronously via fetch + SVGUtils', async () => {
486
+ mockUrlAssets.set(`${JAR_BASE}/onejs/assets/icons/spinner.svg`, "<svg>spin</svg>");
487
+
488
+ const container = createMockContainer();
489
+ render(<Image src="icons/spinner.svg" />, container as any);
490
+ await flushMicrotasks();
491
+
492
+ const el = container.children[0] as any;
493
+ expect(el.image).toBeInstanceOf(MockVectorImage);
494
+ expect((el.image as MockVectorImage).svgText).toBe("<svg>spin</svg>");
495
+ });
496
+
497
+ it('keeps a null image and logs an error when the asset is missing', async () => {
498
+ const container = createMockContainer();
499
+ render(<Image src="images/missing.png" />, container as any);
500
+ await flushMicrotasks();
501
+
502
+ const el = container.children[0] as any;
503
+ expect(el.image).toBeNull();
504
+ expect(console.error).toHaveBeenCalledWith(
505
+ expect.stringContaining("Image src not found: images/missing.png")
506
+ );
507
+ });
508
+
509
+ it('dedupes concurrent loads and serves later mounts from the cache', async () => {
510
+ mockUrlAssets.set(`${JAR_BASE}/onejs/assets/a.png`, [0x89]);
511
+ const cs = (globalThis as any).CS;
512
+ const loadSpy = vi.spyOn(cs.OneJS.Network, 'LoadTextureFromUrl');
513
+
514
+ // Two simultaneous mounts of the same src share one request
515
+ const container1 = createMockContainer();
516
+ render(
517
+ <View>
518
+ <Image src="a.png" />
519
+ <Image src="a.png" />
520
+ </View>,
521
+ container1 as any
522
+ );
523
+ await flushMicrotasks();
524
+
525
+ expect(loadSpy).toHaveBeenCalledTimes(1);
526
+ const view = container1.children[0];
527
+ expect((view.children[0] as any).image).toBeInstanceOf(MockTexture2D);
528
+ expect((view.children[1] as any).image).toBeInstanceOf(MockTexture2D);
529
+
530
+ // A later mount resolves synchronously from the cache
531
+ const container2 = createMockContainer();
532
+ render(<Image src="a.png" />, container2 as any);
533
+ await flushMicrotasks();
534
+
535
+ expect(loadSpy).toHaveBeenCalledTimes(1);
536
+ expect((container2.children[0] as any).image).toBeInstanceOf(MockTexture2D);
537
+ });
446
538
  });
447
539
 
448
540
  describe('event handler mapping', () => {
@@ -263,11 +263,26 @@ export class MockTexture2D {
263
263
  }
264
264
  }
265
265
 
266
+ /**
267
+ * Mock VectorImage as produced by SVGUtils.LoadFromString
268
+ */
269
+ export class MockVectorImage {
270
+ constructor(public svgText: string) {}
271
+ }
272
+
266
273
  /**
267
274
  * Mock file system for image loading tests.
268
- * Tests can add entries to control which files "exist" and what bytes they contain.
275
+ * Tests can add entries to control which files "exist" and what they contain:
276
+ * number[] entries are raster bytes, string entries are text (e.g. SVG source).
277
+ */
278
+ export const mockFileSystem = new Map<string, number[] | string>()
279
+
280
+ /**
281
+ * Mock URL-addressable assets for StreamingAssets-as-URL platforms (Android
282
+ * APK jar: URLs, WebGL http URLs). Keyed by full URL; number[] entries are
283
+ * served by Network.LoadTextureFromUrl, string entries by the fetch stub.
269
284
  */
270
- export const mockFileSystem = new Map<string, number[]>()
285
+ export const mockUrlAssets = new Map<string, number[] | string>()
271
286
 
272
287
  /**
273
288
  * Create the mock CS global object that mirrors QuickJSBootstrap.js proxy
@@ -286,7 +301,14 @@ export function createMockCS() {
286
301
  },
287
302
  File: {
288
303
  Exists: (path: string) => mockFileSystem.has(path),
289
- ReadAllBytes: (path: string) => mockFileSystem.get(path) || [],
304
+ ReadAllBytes: (path: string) => {
305
+ const entry = mockFileSystem.get(path);
306
+ return Array.isArray(entry) ? entry : [];
307
+ },
308
+ ReadAllText: (path: string) => {
309
+ const entry = mockFileSystem.get(path);
310
+ return typeof entry === "string" ? entry : "";
311
+ },
290
312
  },
291
313
  },
292
314
  },
@@ -345,6 +367,20 @@ export function createMockCS() {
345
367
  },
346
368
  },
347
369
  OneJS: {
370
+ SVGUtils: {
371
+ LoadFromString: (svgText: string) => new MockVectorImage(svgText),
372
+ },
373
+ // Mirrors CS.OneJS.Network (Network.cs): LoadTextureFromUrl resolves
374
+ // to a Texture2D on success, null on failure (no throw).
375
+ Network: {
376
+ LoadTextureFromUrl: async (url: string) => {
377
+ const entry = mockUrlAssets.get(url);
378
+ if (!Array.isArray(entry)) return null;
379
+ const tex = new MockTexture2D(2, 2);
380
+ tex.LoadImage(entry);
381
+ return tex;
382
+ },
383
+ },
348
384
  GPU: {
349
385
  GPUBridge: {
350
386
  SetElementBackgroundImage: () => {},
@@ -409,6 +445,7 @@ export function findElementByHandle(handle: number): MockVisualElement | undefin
409
445
  export function resetAllMocks(): void {
410
446
  createdElements = [];
411
447
  mockFileSystem.clear();
448
+ mockUrlAssets.clear();
412
449
  }
413
450
 
414
451
  /**
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { vi, beforeEach, afterEach } from "vitest";
12
- import { createMockCS, resetAllMocks } from "./mocks";
12
+ import { createMockCS, resetAllMocks, mockUrlAssets } from "./mocks";
13
13
  import { clearImageCache } from "../components";
14
14
 
15
15
  // Extend globalThis type for our mocks
@@ -60,9 +60,20 @@ beforeEach(() => {
60
60
  // Use real setTimeout/clearTimeout - React scheduler depends on them
61
61
  (global as any).setTimeout = originalSetTimeout;
62
62
  (global as any).clearTimeout = originalClearTimeout;
63
+
64
+ // Mock fetch serving text entries from mockUrlAssets (mirrors the
65
+ // UnityWebRequest-backed fetch from QuickJSBootstrap.js)
66
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
67
+ const entry = mockUrlAssets.get(url);
68
+ if (typeof entry !== "string") {
69
+ return { ok: false, status: 404, statusText: "Not Found", text: async () => "" };
70
+ }
71
+ return { ok: true, status: 200, statusText: "OK", text: async () => entry };
72
+ }));
63
73
  });
64
74
 
65
75
  // Clean up after each test
66
76
  afterEach(() => {
67
77
  vi.clearAllMocks();
78
+ vi.unstubAllGlobals();
68
79
  });
@@ -1,4 +1,4 @@
1
- import { forwardRef, createElement, useMemo, type ReactElement, type Ref } from 'react';
1
+ import { forwardRef, createElement, useEffect, useMemo, useState, type ReactElement, type Ref } from 'react';
2
2
  import type {
3
3
  BaseProps,
4
4
  ViewProps,
@@ -33,6 +33,14 @@ useExtensions(CS.UnityEngine.ImageConversion)
33
33
 
34
34
  // Module-level image cache shared across all Image instances
35
35
  const _imageCache = new Map<string, any>()
36
+ // In-flight async loads keyed by src, so simultaneous mounts share one request
37
+ const _imagePending = new Map<string, Promise<any>>()
38
+
39
+ // On Android, streamingAssetsPath is a jar:file://...apk!/assets URL; on WebGL
40
+ // it's http(s). System.IO.File can't read those - they need UnityWebRequest.
41
+ function _isUrlPath(path: string): boolean {
42
+ return path.includes("://")
43
+ }
36
44
 
37
45
  function _resolveAssetPath(src: string): string {
38
46
  const Path = CS.System.IO.Path
@@ -46,7 +54,11 @@ function _resolveAssetPath(src: string): string {
46
54
  : Path.Combine(Path.GetDirectoryName(CS.UnityEngine.Application.dataPath), "App")
47
55
  return Path.Combine(workingDir, "assets", src)
48
56
  }
49
- return Path.Combine(CS.UnityEngine.Application.streamingAssetsPath, "onejs", "assets", src)
57
+ const streamingAssets = CS.UnityEngine.Application.streamingAssetsPath
58
+ if (_isUrlPath(streamingAssets)) {
59
+ return `${streamingAssets}/onejs/assets/${src}`
60
+ }
61
+ return Path.Combine(streamingAssets, "onejs", "assets", src)
50
62
  }
51
63
 
52
64
  function _loadImageAsset(src: string): any | null {
@@ -54,6 +66,9 @@ function _loadImageAsset(src: string): any | null {
54
66
  if (cached) return cached
55
67
 
56
68
  const fullPath = _resolveAssetPath(src)
69
+ // URL paths (Android APK, WebGL) can't be read synchronously; the Image
70
+ // component falls back to _loadImageAssetAsync for these.
71
+ if (_isUrlPath(fullPath)) return null
57
72
  if (!CS.System.IO.File.Exists(fullPath)) {
58
73
  console.error(`Image src not found: ${src} (resolved to ${fullPath})`)
59
74
  return null
@@ -75,12 +90,52 @@ function _loadImageAsset(src: string): any | null {
75
90
  return result
76
91
  }
77
92
 
93
+ function _loadImageAssetAsync(src: string, url: string): Promise<any> {
94
+ const existing = _imagePending.get(src)
95
+ if (existing) return existing
96
+
97
+ const promise = (async (): Promise<any> => {
98
+ try {
99
+ let result: any
100
+ if (src.toLowerCase().endsWith(".svg")) {
101
+ const res = await fetch(url)
102
+ if (!res.ok) {
103
+ console.error(`Image src not found: ${src} (resolved to ${url})`)
104
+ return null
105
+ }
106
+ const svgText = await res.text()
107
+ result = CS.OneJS.SVGUtils.LoadFromString(svgText)
108
+ } else {
109
+ const tex = await CS.OneJS.Network.LoadTextureFromUrl(url)
110
+ if (!tex) {
111
+ console.error(`Image src not found: ${src} (resolved to ${url})`)
112
+ return null
113
+ }
114
+ tex.filterMode = CS.UnityEngine.FilterMode.Bilinear
115
+ result = tex
116
+ }
117
+ _imageCache.set(src, result)
118
+ return result
119
+ } catch (e) {
120
+ console.error(`Image src failed to load: ${src} (resolved to ${url}): ${e}`)
121
+ return null
122
+ }
123
+ })()
124
+
125
+ _imagePending.set(src, promise)
126
+ promise.then(() => {
127
+ if (_imagePending.get(src) === promise) _imagePending.delete(src)
128
+ })
129
+ return promise
130
+ }
131
+
78
132
  /**
79
133
  * Clear the Image component's image cache.
80
134
  * Call this if you need to force-reload images (e.g., after replacing files on disk).
81
135
  */
82
136
  export function clearImageCache(): void {
83
137
  _imageCache.clear()
138
+ _imagePending.clear()
84
139
  }
85
140
 
86
141
  // Props with ref support for intrinsic elements
@@ -170,11 +225,27 @@ export const ScrollView = forwardRef<ScrollViewElement, ScrollViewProps>((props,
170
225
  ScrollView.displayName = 'ScrollView';
171
226
 
172
227
  export const Image = forwardRef<ImageElement, ImageProps>(({ src, image, ...rest }, ref) => {
228
+ const [loaded, setLoaded] = useState<{ src: string; image: any } | null>(null)
173
229
  const resolved = useMemo(() => {
174
230
  if (src) return _loadImageAsset(src)
175
231
  return image
176
232
  }, [src, image])
177
- return <ojs-image ref={ref} image={resolved} {...rest} />;
233
+
234
+ // Async fallback for platforms where StreamingAssets is a URL (Android APK,
235
+ // WebGL): load via UnityWebRequest, then re-render with the result.
236
+ useEffect(() => {
237
+ if (!src || resolved) return
238
+ const fullPath = _resolveAssetPath(src)
239
+ if (!_isUrlPath(fullPath)) return
240
+ let cancelled = false
241
+ _loadImageAssetAsync(src, fullPath).then((result) => {
242
+ if (!cancelled && result) setLoaded({ src, image: result })
243
+ })
244
+ return () => { cancelled = true }
245
+ }, [src, resolved])
246
+
247
+ const asyncImage = loaded && loaded.src === src ? loaded.image : null
248
+ return <ojs-image ref={ref} image={resolved ?? asyncImage} {...rest} />;
178
249
  });
179
250
  Image.displayName = 'Image';
180
251
 
@@ -234,7 +234,6 @@ const EVENT_PROPS: Record<string, string> = {
234
234
  onPointerCancel: 'pointercancel',
235
235
  onPointerCapture: 'pointercapture',
236
236
  onPointerCaptureOut: 'pointercaptureout',
237
- onPointerStationary: 'pointerstationary',
238
237
 
239
238
  // Mouse events
240
239
  onMouseDown: 'mousedown',
package/src/types.ts CHANGED
@@ -436,7 +436,6 @@ export interface BaseProps {
436
436
  onPointerCancel?: PointerEventHandler;
437
437
  onPointerCapture?: PointerEventHandler;
438
438
  onPointerCaptureOut?: PointerEventHandler;
439
- onPointerStationary?: PointerEventHandler;
440
439
 
441
440
  // Mouse events
442
441
  onMouseDown?: MouseEventHandler;