diff --git a/package.json b/package.json index 653cc4027c..a9ed12a92e 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.4.0", "expo-asset": "^8.4.6", + "expo-file-system": "^15.4.3", "expo-gl": "^11.1.2", "husky": "^7.0.4", "jest": "^29.3.1", diff --git a/packages/fiber/__mocks__/expo-asset.ts b/packages/fiber/__mocks__/expo-asset.ts index 2f770f5c17..9f87ffec9b 100644 --- a/packages/fiber/__mocks__/expo-asset.ts +++ b/packages/fiber/__mocks__/expo-asset.ts @@ -6,9 +6,9 @@ class Asset { localUri = 'test://null' width = 800 height = 400 - static fromURI = () => this - static fromModule = () => this - static downloadAsync = async () => new Promise((res) => res(this)) + static fromURI = () => new Asset() + static fromModule = () => new Asset() + downloadAsync = async () => new Promise((res) => res(this)) } export { Asset } diff --git a/packages/fiber/__mocks__/react-native/Libraries/Blob/BlobManager.js b/packages/fiber/__mocks__/react-native/Libraries/Blob/BlobManager.js new file mode 100644 index 0000000000..c707dc18f7 --- /dev/null +++ b/packages/fiber/__mocks__/react-native/Libraries/Blob/BlobManager.js @@ -0,0 +1,4 @@ +export default class BlobManager { + createFromParts() {} + createFromOptions() {} +} diff --git a/packages/fiber/__mocks__/react-native/index.ts b/packages/fiber/__mocks__/react-native/index.ts index f73c732922..0a0a257454 100644 --- a/packages/fiber/__mocks__/react-native/index.ts +++ b/packages/fiber/__mocks__/react-native/index.ts @@ -35,3 +35,13 @@ export const StyleSheet = { bottom: 0, }, } + +export const Image = { + getSize(_uri: string, res: Function, rej?: Function) { + res(1, 1) + }, +} + +export const Platform = { + OS: 'web', +} diff --git a/packages/fiber/package.json b/packages/fiber/package.json index 6503bc4b8e..65a7e051f1 100644 --- a/packages/fiber/package.json +++ b/packages/fiber/package.json @@ -44,6 +44,7 @@ "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.26.7", + "base64-js": "^1.5.1", "its-fine": "^1.0.6", "react-reconciler": "^0.27.0", "react-use-measure": "^2.1.1", @@ -55,6 +56,7 @@ "expo": ">=43.0", "expo-asset": ">=8.4", "expo-gl": ">=11.0", + "expo-file-system": ">=11.0", "react": ">=18.0", "react-dom": ">=18.0", "react-native": ">=0.64", @@ -73,6 +75,9 @@ "expo-asset": { "optional": true }, + "expo-file-system": { + "optional": true + }, "expo-gl": { "optional": true } diff --git a/packages/fiber/src/native.tsx b/packages/fiber/src/native.tsx index 3523b0c214..dfa9214efe 100644 --- a/packages/fiber/src/native.tsx +++ b/packages/fiber/src/native.tsx @@ -19,3 +19,6 @@ export * from './native/Canvas' export { createTouchEvents as events } from './native/events' export type { GlobalRenderCallback, GlobalEffectType } from './core/loop' export * from './core' + +import { polyfills } from './native/polyfills' +polyfills() diff --git a/packages/fiber/src/native/Canvas.tsx b/packages/fiber/src/native/Canvas.tsx index 8182cf3fd5..5361ed28ce 100644 --- a/packages/fiber/src/native/Canvas.tsx +++ b/packages/fiber/src/native/Canvas.tsx @@ -7,7 +7,6 @@ import { SetBlock, Block, ErrorBoundary, useMutableCallback } from '../core/util import { extend, createRoot, unmountComponentAtNode, RenderProps, ReconcilerRoot } from '../core' import { createTouchEvents } from './events' import { RootState, Size } from '../core/store' -import { polyfills } from './polyfills' export interface CanvasProps extends Omit, 'size' | 'dpr'>, ViewProps { children: React.ReactNode @@ -67,9 +66,6 @@ const CanvasImpl = /*#__PURE__*/ React.forwardRef( const viewRef = React.useRef(null!) const root = React.useRef>(null!) - // Inject and cleanup RN polyfills if able - React.useLayoutEffect(() => polyfills(), []) - const onLayout = React.useCallback((e: LayoutChangeEvent) => { const { width, height, x, y } = e.nativeEvent.layout setSize({ width, height, top: y, left: x }) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index c3653c1beb..cfd41ac065 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -1,51 +1,114 @@ import * as THREE from 'three' -import type { Asset } from 'expo-asset' - -// Check if expo-asset is installed (available with expo modules) -let expAsset: typeof Asset | undefined -try { - expAsset = require('expo-asset')?.Asset -} catch (_) {} - -/** - * Generates an asset based on input type. - */ -function getAsset(input: string | number) { - switch (typeof input) { - case 'string': - return expAsset!.fromURI(input) - case 'number': - return expAsset!.fromModule(input) - default: - throw new Error('R3F: Invalid asset! Must be a URI or module.') +import { Image } from 'react-native' +import { Asset } from 'expo-asset' +import * as fs from 'expo-file-system' +import { fromByteArray } from 'base64-js' + +export function polyfills() { + // Patch Blob for ArrayBuffer if unsupported + try { + new Blob([new ArrayBuffer(4) as any]) + } catch (_) { + global.Blob = class extends Blob { + constructor(parts?: any[], options?: any) { + super( + parts?.map((part) => { + if (part instanceof ArrayBuffer || ArrayBuffer.isView(part)) { + part = fromByteArray(new Uint8Array(part as ArrayBuffer)) + } + + return part + }), + options, + ) + } + } } -} -let injected = false + function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0, + v = c == 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) + } -export function polyfills() { - if (!expAsset || injected) return - injected = true + async function getAsset(input: string | number): Promise { + if (typeof input === 'string') { + // Point to storage if preceded with fs path + if (input.startsWith('file:')) return { localUri: input } as Asset + + // Unpack Blobs from react-native BlobManager + if (input.startsWith('blob:')) { + const blob = await new Promise((res, rej) => { + const xhr = new XMLHttpRequest() + xhr.open('GET', input as string) + xhr.responseType = 'blob' + xhr.onload = () => res(xhr.response) + xhr.onerror = rej + xhr.send() + }) + + const data = await new Promise((res, rej) => { + const reader = new FileReader() + reader.onload = () => res(reader.result as string) + reader.onerror = rej + reader.readAsText(blob) + }) + + input = `data:${blob.type};base64,${data}` + } + + // Create safe URI for JSI + if (input.startsWith('data:')) { + const [header, data] = input.split(',') + const [, type] = header.split('/') + + const localUri = fs.cacheDirectory + uuidv4() + `.${type}` + await fs.writeAsStringAsync(localUri, data, { encoding: fs.EncodingType.Base64 }) + + return { localUri } as Asset + } + } + + // Download bundler module or external URL + const asset = Asset.fromModule(input) + + // Unpack assets in Android Release Mode + if (!asset.uri.includes(':')) { + const localUri = `${fs.cacheDirectory}ExponentAsset-${asset.hash}.${asset.type}` + await fs.copyAsync({ from: asset.uri, to: localUri }) + return { localUri } as Asset + } + + // Otherwise, resolve from registry + return asset.downloadAsync() + } // Don't pre-process urls, let expo-asset generate an absolute URL const extractUrlBase = THREE.LoaderUtils.extractUrlBase.bind(THREE.LoaderUtils) THREE.LoaderUtils.extractUrlBase = (url: string) => (typeof url === 'string' ? extractUrlBase(url) : './') // There's no Image in native, so create a data texture instead - const prevTextureLoad = THREE.TextureLoader.prototype.load THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { if (this.path) url = this.path + url const texture = new THREE.Texture() - // @ts-ignore - texture.isDataTexture = true - getAsset(url) - .downloadAsync() - .then((asset: Asset) => { + .then(async (asset: Asset) => { + const uri = asset.localUri || asset.uri + + if (!asset.width || !asset.height) { + const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => + Image.getSize(uri, (width, height) => res({ width, height }), rej), + ) + asset.width = width + asset.height = height + } + texture.image = { - data: asset, + data: { localUri: uri }, width: asset.width, height: asset.height, } @@ -53,6 +116,10 @@ export function polyfills() { texture.unpackAlignment = 1 texture.needsUpdate = true + // Force non-DOM upload for EXGL fast paths + // @ts-ignore + texture.isDataTexture = true + onLoad?.(texture) }) .catch(onError) @@ -61,16 +128,22 @@ export function polyfills() { } // Fetches assets via XMLHttpRequest - const prevFileLoad = THREE.FileLoader.prototype.load - THREE.FileLoader.prototype.load = function (url, onLoad, onProgress, onError) { + THREE.FileLoader.prototype.load = function load(url, onLoad, onProgress, onError) { if (this.path) url = this.path + url const request = new XMLHttpRequest() getAsset(url) - .downloadAsync() - .then((asset) => { - request.open('GET', asset.uri, true) + .then(async (asset) => { + let uri = asset.localUri || asset.uri + + // Make FS paths web-safe + if (asset.uri.startsWith('file://')) { + const data = await fs.readAsStringAsync(asset.uri, { encoding: fs.EncodingType.Base64 }) + uri = `data:application/octet-stream;base64,${data}` + } + + request.open('GET', uri, true) request.addEventListener( 'load', @@ -130,14 +203,8 @@ export function polyfills() { this.manager.itemStart(url) }) + .catch(onError) return request } - - // Cleanup function - return () => { - THREE.LoaderUtils.extractUrlBase = extractUrlBase - THREE.TextureLoader.prototype.load = prevTextureLoad - THREE.FileLoader.prototype.load = prevFileLoad - } } diff --git a/packages/fiber/tests/native/hooks.test.tsx b/packages/fiber/tests/native/hooks.test.tsx index 3b72c18e3a..75e9c65221 100644 --- a/packages/fiber/tests/native/hooks.test.tsx +++ b/packages/fiber/tests/native/hooks.test.tsx @@ -5,9 +5,6 @@ import { createCanvas } from '@react-three/test-renderer/src/createTestCanvas' import { waitFor } from '@react-three/test-renderer' import { createRoot, useLoader, act } from '../../src/native' -import { polyfills } from '../../src/native/polyfills' - -polyfills() describe('useLoader', () => { let canvas: HTMLCanvasElement = null! diff --git a/yarn.lock b/yarn.lock index 2954dfa779..16f4f7ea20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4800,6 +4800,13 @@ expo-asset@^8.4.6: path-browserify "^1.0.0" url-parse "^1.4.4" +expo-file-system@^15.4.3: + version "15.4.3" + resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-15.4.3.tgz#0cb2464c6e663ad8e8a742d5c538ed8ff1013b11" + integrity sha512-HaaCBTUATs2+i7T4jxIvoU9rViAHMvOD2eBaJ1H7xPHlwZlMORjQs7bsNKonR/TQoduxZBJLVZGawvaAJNCH8g== + dependencies: + uuid "^3.4.0" + expo-gl-cpp@~11.1.0: version "11.1.1" resolved "https://registry.yarnpkg.com/expo-gl-cpp/-/expo-gl-cpp-11.1.1.tgz#883781535658a3598f2262425b1d3527b0e72760" @@ -10078,7 +10085,7 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@^3.3.2: +uuid@^3.3.2, uuid@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==