Skip to content

Commit

Permalink
experiment(native): implement missing Blob/URL/fetch APIs (#2982)
Browse files Browse the repository at this point in the history
  • Loading branch information
CodyJasonBennett committed Sep 4, 2023
1 parent 914fe2b commit 72d7f66
Show file tree
Hide file tree
Showing 10 changed files with 144 additions and 54 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/fiber/__mocks__/expo-asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default class BlobManager {
createFromParts() {}
createFromOptions() {}
}
10 changes: 10 additions & 0 deletions packages/fiber/__mocks__/react-native/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
5 changes: 5 additions & 0 deletions packages/fiber/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -73,6 +75,9 @@
"expo-asset": {
"optional": true
},
"expo-file-system": {
"optional": true
},
"expo-gl": {
"optional": true
}
Expand Down
3 changes: 3 additions & 0 deletions packages/fiber/src/native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
4 changes: 0 additions & 4 deletions packages/fiber/src/native/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RenderProps<HTMLCanvasElement>, 'size' | 'dpr'>, ViewProps {
children: React.ReactNode
Expand Down Expand Up @@ -67,9 +66,6 @@ const CanvasImpl = /*#__PURE__*/ React.forwardRef<View, Props>(
const viewRef = React.useRef<View>(null!)
const root = React.useRef<ReconcilerRoot<HTMLCanvasElement>>(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 })
Expand Down
153 changes: 110 additions & 43 deletions packages/fiber/src/native/polyfills.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,125 @@
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<Asset> {
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<Blob>((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<string>((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,
}
texture.flipY = true
texture.unpackAlignment = 1
texture.needsUpdate = true

// Force non-DOM upload for EXGL fast paths
// @ts-ignore
texture.isDataTexture = true

onLoad?.(texture)
})
.catch(onError)
Expand All @@ -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',
Expand Down Expand Up @@ -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
}
}
3 changes: 0 additions & 3 deletions packages/fiber/tests/native/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
9 changes: 8 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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==
Expand Down

0 comments on commit 72d7f66

Please sign in to comment.