From 9391975aded703e78b1da15da80b1fb190362c7d Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Wed, 30 Aug 2023 23:25:53 -0500 Subject: [PATCH 01/19] fix(native): special case data uris --- packages/fiber/src/native/polyfills.ts | 29 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index 46877de33a..9358058a90 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -1,4 +1,5 @@ import * as THREE from 'three' +import { Image } from 'react-native' import type { Asset } from 'expo-asset' // Check if expo-asset is installed (available with expo modules) @@ -10,12 +11,13 @@ try { /** * Generates an asset based on input type. */ -function getAsset(input: string | number) { +async function getAsset(input: string | number): Promise { switch (typeof input) { case 'string': - return expAsset!.fromURI(input) + if (input.startsWith('data:')) return { localUri: input } as Asset + return expAsset!.fromURI(input).downloadAsync() case 'number': - return expAsset!.fromModule(input) + return expAsset!.fromModule(input).downloadAsync() default: throw new Error('R3F: Invalid asset! Must be a URI or module.') } @@ -36,21 +38,28 @@ export function polyfills() { THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { const texture = new THREE.Texture() - // @ts-ignore - texture.isDataTexture = true - getAsset(url) - .downloadAsync() - .then((asset: Asset) => { + .then(async (asset: Asset) => { + if (!asset.width || !asset.height) { + const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => + Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), + ) + asset.width = width + asset.height = height + } + texture.image = { data: asset, width: asset.width, height: asset.height, } - texture.flipY = true + texture.flipY = false texture.unpackAlignment = 1 texture.needsUpdate = true + // @ts-ignore + texture.isDataTexture = true + onLoad?.(texture) }) .catch(onError) @@ -66,7 +75,6 @@ export function polyfills() { const request = new XMLHttpRequest() getAsset(url) - .downloadAsync() .then((asset) => { request.open('GET', asset.uri, true) @@ -128,6 +136,7 @@ export function polyfills() { this.manager.itemStart(url) }) + .catch(onError) return request } From 25e477aa18a89afa64a81654713015fe4de9a8de Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Wed, 30 Aug 2023 23:27:51 -0500 Subject: [PATCH 02/19] Update polyfills.ts --- packages/fiber/src/native/polyfills.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index 9358058a90..9545043eb7 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -53,7 +53,7 @@ export function polyfills() { width: asset.width, height: asset.height, } - texture.flipY = false + texture.flipY = true texture.unpackAlignment = 1 texture.needsUpdate = true From b7fb636e6bf2fccc73c0a3ecd2bac5757b48846a Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Wed, 30 Aug 2023 23:28:18 -0500 Subject: [PATCH 03/19] Update polyfills.ts --- packages/fiber/src/native/polyfills.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index 9545043eb7..7b79e821c5 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -38,6 +38,9 @@ export function polyfills() { THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { const texture = new THREE.Texture() + // @ts-ignore + texture.isDataTexture = true + getAsset(url) .then(async (asset: Asset) => { if (!asset.width || !asset.height) { @@ -57,9 +60,6 @@ export function polyfills() { texture.unpackAlignment = 1 texture.needsUpdate = true - // @ts-ignore - texture.isDataTexture = true - onLoad?.(texture) }) .catch(onError) From 25bd6cfe8b8ba61fa6c5dcf68d6c261018c8e814 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Wed, 30 Aug 2023 23:30:06 -0500 Subject: [PATCH 04/19] chore: update mock --- packages/fiber/__mocks__/react-native/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/fiber/__mocks__/react-native/index.ts b/packages/fiber/__mocks__/react-native/index.ts index f73c732922..4ebd98670c 100644 --- a/packages/fiber/__mocks__/react-native/index.ts +++ b/packages/fiber/__mocks__/react-native/index.ts @@ -35,3 +35,9 @@ export const StyleSheet = { bottom: 0, }, } + +export const Image = { + getSize(_uri: string, res: Function, rej?: Function) { + res(1, 1) + }, +} From 1873be2c58f957a77ca26affafbc37a2b82405cc Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:09:46 -0500 Subject: [PATCH 05/19] refactor: effectful polyfill, WIP Blob patches --- packages/fiber/src/native.tsx | 1 + packages/fiber/src/native/Canvas.tsx | 4 - packages/fiber/src/native/polyfills.ts | 323 +++++++++++++++---------- 3 files changed, 202 insertions(+), 126 deletions(-) diff --git a/packages/fiber/src/native.tsx b/packages/fiber/src/native.tsx index 3523b0c214..2c4f3a9a0d 100644 --- a/packages/fiber/src/native.tsx +++ b/packages/fiber/src/native.tsx @@ -19,3 +19,4 @@ export * from './native/Canvas' export { createTouchEvents as events } from './native/events' export type { GlobalRenderCallback, GlobalEffectType } from './core/loop' export * from './core' +import './native/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 7b79e821c5..c0253cfbab 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -1,150 +1,229 @@ import * as THREE from 'three' -import { Image } from 'react-native' +import { Platform, NativeModules, Image } from 'react-native' 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. - */ -async function getAsset(input: string | number): Promise { - switch (typeof input) { - case 'string': - if (input.startsWith('data:')) return { localUri: input } as Asset - return expAsset!.fromURI(input).downloadAsync() - case 'number': - return expAsset!.fromModule(input).downloadAsync() - default: - throw new Error('R3F: Invalid asset! Must be a URI or module.') - } -} - -let injected = false +if (Platform.OS !== 'web') { + const BlobManager = require('react-native/Libraries/Blob/BlobManager.js') + const { fromByteArray } = require('base64-js') -export function polyfills() { - if (!expAsset || injected) return - injected = true + 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) + }) + } - // 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) : './') + const { BlobModule } = NativeModules + let BLOB_URL_PREFIX: string | null = null - // 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) { - const texture = new THREE.Texture() + if (BlobModule && typeof BlobModule.BLOB_URI_SCHEME === 'string') { + BLOB_URL_PREFIX = BlobModule.BLOB_URI_SCHEME + ':' + if (typeof BlobModule.BLOB_URI_HOST === 'string') { + BLOB_URL_PREFIX += `//${BlobModule.BLOB_URI_HOST}/` + } + } - // @ts-ignore - texture.isDataTexture = true + BlobManager.createFromParts = function createFromParts(parts: Array, options: any) { + const blobId = uuidv4() - getAsset(url) - .then(async (asset: Asset) => { - if (!asset.width || !asset.height) { - const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => - Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), - ) - asset.width = width - asset.height = height + const items = parts.map((part) => { + if (part instanceof ArrayBuffer || ArrayBuffer.isView(part)) { + const data = fromByteArray(new Uint8Array(part as ArrayBuffer)) + return { + data, + type: 'string', } - - texture.image = { - data: asset, - width: asset.width, - height: asset.height, + } else if (part instanceof Blob) { + return { + data: (part as any).data, + type: 'blob', } - texture.flipY = true - texture.unpackAlignment = 1 - texture.needsUpdate = true - - onLoad?.(texture) - }) - .catch(onError) + } else { + return { + data: String(part), + type: 'string', + } + } + }) + const size = items.reduce((acc, curr) => { + if (curr.type === 'string') { + return acc + global.unescape(encodeURI(curr.data)).length + } else { + return acc + curr.data.size + } + }, 0) + + BlobModule.createFromParts(items, blobId) + + return BlobManager.createFromOptions({ + blobId, + offset: 0, + size, + type: options ? options.type : '', + lastModified: options ? options.lastModified : Date.now(), + }) + } - return texture + URL.createObjectURL = function createObjectURL(blob) { + if (BLOB_URL_PREFIX === null) { + throw new Error('Cannot create URL for blob!') + } + // @ts-ignore + return `${BLOB_URL_PREFIX}${blob.data.blobId}?offset=${blob.data.offset}&size=${blob.size}` } - // Fetches assets via XMLHttpRequest - const prevFileLoad = THREE.FileLoader.prototype.load - THREE.FileLoader.prototype.load = function (url, onLoad, onProgress, onError) { - if (this.path) url = this.path + url + // 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. + */ + async function getAsset(input: string | number): Promise { + switch (typeof input) { + case 'string': + if (input.startsWith('data:')) return { localUri: input } as Asset + if (input.startsWith('blob:')) { + const blob = await new Promise((res, rej) => { + const xhr = new XMLHttpRequest() + xhr.open('GET', input) + 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) + }) + + const localUri = `data:${blob.type};base64,${data}` + + return getAsset(localUri) + } + return expAsset!.fromURI(input).downloadAsync() + case 'number': + return expAsset!.fromModule(input).downloadAsync() + default: + throw new Error('R3F: Invalid asset! Must be a URI or module.') + } + } - const request = new XMLHttpRequest() + if (expAsset) { + // 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 + THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { + const texture = new THREE.Texture() + + getAsset(url) + .then(async (asset: Asset) => { + if (!asset.width || !asset.height) { + const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => + Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), + ) + asset.width = width + asset.height = height + } + + texture.image = { + data: { localUri: asset.localUri }, + width: asset.width, + height: asset.height, + } + texture.flipY = true + // texture.unpackAlignment = 1 + texture.needsUpdate = true + + // @ts-ignore + texture.isDataTexture = true + + onLoad?.(texture) + }) + .catch(onError) + + return texture + } + + // Fetches assets via XMLHttpRequest + THREE.FileLoader.prototype.load = function load(url, onLoad, onProgress, onError) { + if (this.path) url = this.path + url + + const request = new XMLHttpRequest() + + getAsset(url) + .then((asset) => { + request.open('GET', asset.uri, true) + + request.addEventListener( + 'load', + (event) => { + if (request.status === 200) { + onLoad?.(request.response) + + this.manager.itemEnd(url) + } else { + onError?.(event as unknown as ErrorEvent) + + this.manager.itemError(url) + this.manager.itemEnd(url) + } + }, + false, + ) - getAsset(url) - .then((asset) => { - request.open('GET', asset.uri, true) + request.addEventListener( + 'progress', + (event) => { + onProgress?.(event) + }, + false, + ) - request.addEventListener( - 'load', - (event) => { - if (request.status === 200) { - onLoad?.(request.response) + request.addEventListener( + 'error', + (event) => { + onError?.(event as unknown as ErrorEvent) + this.manager.itemError(url) this.manager.itemEnd(url) - } else { + }, + false, + ) + + request.addEventListener( + 'abort', + (event) => { onError?.(event as unknown as ErrorEvent) this.manager.itemError(url) this.manager.itemEnd(url) - } - }, - false, - ) - - request.addEventListener( - 'progress', - (event) => { - onProgress?.(event) - }, - false, - ) - - request.addEventListener( - 'error', - (event) => { - onError?.(event as unknown as ErrorEvent) - - this.manager.itemError(url) - this.manager.itemEnd(url) - }, - false, - ) - - request.addEventListener( - 'abort', - (event) => { - onError?.(event as unknown as ErrorEvent) - - this.manager.itemError(url) - this.manager.itemEnd(url) - }, - false, - ) - - if (this.responseType) request.responseType = this.responseType - if (this.withCredentials) request.withCredentials = this.withCredentials - - for (const header in this.requestHeader) { - request.setRequestHeader(header, this.requestHeader[header]) - } + }, + false, + ) - request.send(null) + if (this.responseType) request.responseType = this.responseType + if (this.withCredentials) request.withCredentials = this.withCredentials - this.manager.itemStart(url) - }) - .catch(onError) + for (const header in this.requestHeader) { + request.setRequestHeader(header, this.requestHeader[header]) + } - return request - } + request.send(null) + + this.manager.itemStart(url) + }) + .catch(onError) - // Cleanup function - return () => { - THREE.LoaderUtils.extractUrlBase = extractUrlBase - THREE.TextureLoader.prototype.load = prevTextureLoad - THREE.FileLoader.prototype.load = prevFileLoad + return request + } } } From 8620d22b5b393b3340b93d0ccd28bc57c4f785de Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:17:43 -0500 Subject: [PATCH 06/19] chore: update mocks --- .../__mocks__/react-native/Libraries/Blob/BlobManager.js | 4 ++++ packages/fiber/__mocks__/react-native/index.ts | 4 ++++ packages/fiber/tests/native/hooks.test.tsx | 5 +---- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 packages/fiber/__mocks__/react-native/Libraries/Blob/BlobManager.js 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 4ebd98670c..0a0a257454 100644 --- a/packages/fiber/__mocks__/react-native/index.ts +++ b/packages/fiber/__mocks__/react-native/index.ts @@ -41,3 +41,7 @@ export const Image = { res(1, 1) }, } + +export const Platform = { + OS: 'web', +} diff --git a/packages/fiber/tests/native/hooks.test.tsx b/packages/fiber/tests/native/hooks.test.tsx index 3b72c18e3a..d51c5bff7b 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! @@ -29,7 +26,7 @@ describe('useLoader', () => { ) }) - it('produces data textures for TextureLoader', async () => { + it.skip('produces data textures for TextureLoader', async () => { let texture: any const Component = () => { From 3c91eac2f7c1fcbcb7cb287a5584d59585bd0dd3 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 31 Aug 2023 19:41:26 -0500 Subject: [PATCH 07/19] fix: offline data to fs for JSI OOM --- package.json | 1 + packages/fiber/package.json | 4 + packages/fiber/src/native/polyfills.ts | 208 ++++++++++++------------- yarn.lock | 9 +- 4 files changed, 117 insertions(+), 105 deletions(-) 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/package.json b/packages/fiber/package.json index 4717d1083c..445d66eff0 100644 --- a/packages/fiber/package.json +++ b/packages/fiber/package.json @@ -55,6 +55,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 +74,9 @@ "expo-asset": { "optional": true }, + "expo-file-system": { + "optional": true + }, "expo-gl": { "optional": true } diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index c0253cfbab..7d53b4401c 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -1,6 +1,7 @@ import * as THREE from 'three' import { Platform, NativeModules, Image } from 'react-native' -import type { Asset } from 'expo-asset' +import { Asset } from 'expo-asset' +import * as fs from 'expo-file-system' if (Platform.OS !== 'web') { const BlobManager = require('react-native/Libraries/Blob/BlobManager.js') @@ -73,20 +74,21 @@ if (Platform.OS !== 'web') { return `${BLOB_URL_PREFIX}${blob.data.blobId}?offset=${blob.data.offset}&size=${blob.size}` } - // 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. */ async function getAsset(input: string | number): Promise { switch (typeof input) { case 'string': - if (input.startsWith('data:')) return { localUri: input } as Asset - if (input.startsWith('blob:')) { + 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 + } else if (input.startsWith('blob:')) { const blob = await new Promise((res, rej) => { const xhr = new XMLHttpRequest() xhr.open('GET', input) @@ -107,123 +109,121 @@ if (Platform.OS !== 'web') { return getAsset(localUri) } - return expAsset!.fromURI(input).downloadAsync() + return Asset.fromURI(input).downloadAsync() case 'number': - return expAsset!.fromModule(input).downloadAsync() + return Asset.fromModule(input).downloadAsync() default: throw new Error('R3F: Invalid asset! Must be a URI or module.') } } - if (expAsset) { - // 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 - THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { - const texture = new THREE.Texture() - - getAsset(url) - .then(async (asset: Asset) => { - if (!asset.width || !asset.height) { - const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => - Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), - ) - asset.width = width - asset.height = height - } - - texture.image = { - data: { localUri: asset.localUri }, - width: asset.width, - height: asset.height, - } - texture.flipY = true - // texture.unpackAlignment = 1 - texture.needsUpdate = true - - // @ts-ignore - texture.isDataTexture = true - - onLoad?.(texture) - }) - .catch(onError) - - return texture - } + // 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) : './') - // Fetches assets via XMLHttpRequest - THREE.FileLoader.prototype.load = function load(url, onLoad, onProgress, onError) { - if (this.path) url = this.path + url + // There's no Image in native, so create a data texture instead + THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { + const texture = new THREE.Texture() - const request = new XMLHttpRequest() + getAsset(url) + .then(async (asset: Asset) => { + if (!asset.width || !asset.height) { + const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => + Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), + ) + asset.width = width + asset.height = height + } - getAsset(url) - .then((asset) => { - request.open('GET', asset.uri, true) + texture.image = { + data: { localUri: asset.localUri }, + width: asset.width, + height: asset.height, + } + texture.flipY = true + // texture.unpackAlignment = 1 + texture.needsUpdate = true - request.addEventListener( - 'load', - (event) => { - if (request.status === 200) { - onLoad?.(request.response) + // @ts-ignore + texture.isDataTexture = true - this.manager.itemEnd(url) - } else { - onError?.(event as unknown as ErrorEvent) + onLoad?.(texture) + }) + .catch(onError) - this.manager.itemError(url) - this.manager.itemEnd(url) - } - }, - false, - ) + return texture + } - request.addEventListener( - 'progress', - (event) => { - onProgress?.(event) - }, - false, - ) + // Fetches assets via XMLHttpRequest + THREE.FileLoader.prototype.load = function load(url, onLoad, onProgress, onError) { + if (this.path) url = this.path + url - request.addEventListener( - 'error', - (event) => { - onError?.(event as unknown as ErrorEvent) + const request = new XMLHttpRequest() - this.manager.itemError(url) - this.manager.itemEnd(url) - }, - false, - ) + getAsset(url) + .then((asset) => { + request.open('GET', asset.uri, true) - request.addEventListener( - 'abort', - (event) => { + request.addEventListener( + 'load', + (event) => { + if (request.status === 200) { + onLoad?.(request.response) + + this.manager.itemEnd(url) + } else { onError?.(event as unknown as ErrorEvent) this.manager.itemError(url) this.manager.itemEnd(url) - }, - false, - ) - - if (this.responseType) request.responseType = this.responseType - if (this.withCredentials) request.withCredentials = this.withCredentials - - for (const header in this.requestHeader) { - request.setRequestHeader(header, this.requestHeader[header]) - } + } + }, + false, + ) + + request.addEventListener( + 'progress', + (event) => { + onProgress?.(event) + }, + false, + ) + + request.addEventListener( + 'error', + (event) => { + onError?.(event as unknown as ErrorEvent) + + this.manager.itemError(url) + this.manager.itemEnd(url) + }, + false, + ) + + request.addEventListener( + 'abort', + (event) => { + onError?.(event as unknown as ErrorEvent) + + this.manager.itemError(url) + this.manager.itemEnd(url) + }, + false, + ) + + if (this.responseType) request.responseType = this.responseType + if (this.withCredentials) request.withCredentials = this.withCredentials + + for (const header in this.requestHeader) { + request.setRequestHeader(header, this.requestHeader[header]) + } - request.send(null) + request.send(null) - this.manager.itemStart(url) - }) - .catch(onError) + this.manager.itemStart(url) + }) + .catch(onError) - return request - } + return request } } 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== From 120bae96b8360cbface6e1bde84afb8915657915 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 31 Aug 2023 20:10:48 -0500 Subject: [PATCH 08/19] fix: unpack modules in Android Release Mode --- packages/fiber/src/native/polyfills.ts | 84 ++++++++++++++------------ 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index 7d53b4401c..8047a5fe54 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -78,43 +78,52 @@ if (Platform.OS !== 'web') { * Generates an asset based on input type. */ async function getAsset(input: string | number): Promise { - switch (typeof input) { - case 'string': - 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 - } else if (input.startsWith('blob:')) { - const blob = await new Promise((res, rej) => { - const xhr = new XMLHttpRequest() - xhr.open('GET', input) - 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) - }) - - const localUri = `data:${blob.type};base64,${data}` - - return getAsset(localUri) - } - return Asset.fromURI(input).downloadAsync() - case 'number': - return Asset.fromModule(input).downloadAsync() - default: - throw new Error('R3F: Invalid asset! Must be a URI or module.') + if (typeof input === 'string') { + // 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 @@ -141,9 +150,10 @@ if (Platform.OS !== 'web') { height: asset.height, } texture.flipY = true - // texture.unpackAlignment = 1 + texture.unpackAlignment = 1 texture.needsUpdate = true + // Force non-DOM upload for EXGL fast paths // @ts-ignore texture.isDataTexture = true From 3979635f0a49a655b995bde636be58253adb7a0e Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 31 Aug 2023 20:22:00 -0500 Subject: [PATCH 09/19] experiment: polyfill as IIFE in entrypoint --- packages/fiber/src/native.tsx | 4 +- packages/fiber/src/native/polyfills.ts | 388 +++++++++++++------------ 2 files changed, 198 insertions(+), 194 deletions(-) diff --git a/packages/fiber/src/native.tsx b/packages/fiber/src/native.tsx index 2c4f3a9a0d..dfa9214efe 100644 --- a/packages/fiber/src/native.tsx +++ b/packages/fiber/src/native.tsx @@ -19,4 +19,6 @@ export * from './native/Canvas' export { createTouchEvents as events } from './native/events' export type { GlobalRenderCallback, GlobalEffectType } from './core/loop' export * from './core' -import './native/polyfills' + +import { polyfills } from './native/polyfills' +polyfills() diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index 8047a5fe54..652085ad27 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -3,237 +3,239 @@ import { Platform, NativeModules, Image } from 'react-native' import { Asset } from 'expo-asset' import * as fs from 'expo-file-system' -if (Platform.OS !== 'web') { - const BlobManager = require('react-native/Libraries/Blob/BlobManager.js') - const { fromByteArray } = require('base64-js') - - 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 (Platform.OS !== 'web') { + const BlobManager = require('react-native/Libraries/Blob/BlobManager.js') + const { fromByteArray } = require('base64-js') + + 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) + }) + } - const { BlobModule } = NativeModules - let BLOB_URL_PREFIX: string | null = null + const { BlobModule } = NativeModules + let BLOB_URL_PREFIX: string | null = null - if (BlobModule && typeof BlobModule.BLOB_URI_SCHEME === 'string') { - BLOB_URL_PREFIX = BlobModule.BLOB_URI_SCHEME + ':' - if (typeof BlobModule.BLOB_URI_HOST === 'string') { - BLOB_URL_PREFIX += `//${BlobModule.BLOB_URI_HOST}/` + if (BlobModule && typeof BlobModule.BLOB_URI_SCHEME === 'string') { + BLOB_URL_PREFIX = BlobModule.BLOB_URI_SCHEME + ':' + if (typeof BlobModule.BLOB_URI_HOST === 'string') { + BLOB_URL_PREFIX += `//${BlobModule.BLOB_URI_HOST}/` + } } - } - - BlobManager.createFromParts = function createFromParts(parts: Array, options: any) { - const blobId = uuidv4() - const items = parts.map((part) => { - if (part instanceof ArrayBuffer || ArrayBuffer.isView(part)) { - const data = fromByteArray(new Uint8Array(part as ArrayBuffer)) - return { - data, - type: 'string', + BlobManager.createFromParts = function createFromParts(parts: Array, options: any) { + const blobId = uuidv4() + + const items = parts.map((part) => { + if (part instanceof ArrayBuffer || ArrayBuffer.isView(part)) { + const data = fromByteArray(new Uint8Array(part as ArrayBuffer)) + return { + data, + type: 'string', + } + } else if (part instanceof Blob) { + return { + data: (part as any).data, + type: 'blob', + } + } else { + return { + data: String(part), + type: 'string', + } } - } else if (part instanceof Blob) { - return { - data: (part as any).data, - type: 'blob', - } - } else { - return { - data: String(part), - type: 'string', + }) + const size = items.reduce((acc, curr) => { + if (curr.type === 'string') { + return acc + global.unescape(encodeURI(curr.data)).length + } else { + return acc + curr.data.size } - } - }) - const size = items.reduce((acc, curr) => { - if (curr.type === 'string') { - return acc + global.unescape(encodeURI(curr.data)).length - } else { - return acc + curr.data.size - } - }, 0) + }, 0) - BlobModule.createFromParts(items, blobId) + BlobModule.createFromParts(items, blobId) - return BlobManager.createFromOptions({ - blobId, - offset: 0, - size, - type: options ? options.type : '', - lastModified: options ? options.lastModified : Date.now(), - }) - } + return BlobManager.createFromOptions({ + blobId, + offset: 0, + size, + type: options ? options.type : '', + lastModified: options ? options.lastModified : Date.now(), + }) + } - URL.createObjectURL = function createObjectURL(blob) { - if (BLOB_URL_PREFIX === null) { - throw new Error('Cannot create URL for blob!') + URL.createObjectURL = function createObjectURL(blob) { + if (BLOB_URL_PREFIX === null) { + throw new Error('Cannot create URL for blob!') + } + // @ts-ignore + return `${BLOB_URL_PREFIX}${blob.data.blobId}?offset=${blob.data.offset}&size=${blob.size}` } - // @ts-ignore - return `${BLOB_URL_PREFIX}${blob.data.blobId}?offset=${blob.data.offset}&size=${blob.size}` - } - /** - * Generates an asset based on input type. - */ - async function getAsset(input: string | number): Promise { - if (typeof input === 'string') { - // 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() - }) + /** + * Generates an asset based on input type. + */ + async function getAsset(input: string | number): Promise { + if (typeof input === 'string') { + // 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}` + } - const data = await new Promise((res, rej) => { - const reader = new FileReader() - reader.onload = () => res(reader.result as string) - reader.onerror = rej - reader.readAsText(blob) - }) + // Create safe URI for JSI + if (input.startsWith('data:')) { + const [header, data] = input.split(',') + const [, type] = header.split('/') - input = `data:${blob.type};base64,${data}` - } + const localUri = fs.cacheDirectory + uuidv4() + `.${type}` + await fs.writeAsStringAsync(localUri, data, { encoding: fs.EncodingType.Base64 }) - // Create safe URI for JSI - if (input.startsWith('data:')) { - const [header, data] = input.split(',') - const [, type] = header.split('/') + return { localUri } as Asset + } + } - const localUri = fs.cacheDirectory + uuidv4() + `.${type}` - await fs.writeAsStringAsync(localUri, data, { encoding: fs.EncodingType.Base64 }) + // 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 } - } - - // 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() } - // 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 - THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { - const texture = new THREE.Texture() + // 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 + THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { + const texture = new THREE.Texture() + + getAsset(url) + .then(async (asset: Asset) => { + if (!asset.width || !asset.height) { + const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => + Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), + ) + asset.width = width + asset.height = height + } + + texture.image = { + data: { localUri: asset.localUri }, + 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) - getAsset(url) - .then(async (asset: Asset) => { - if (!asset.width || !asset.height) { - const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => - Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), - ) - asset.width = width - asset.height = height - } + return texture + } - texture.image = { - data: { localUri: asset.localUri }, - width: asset.width, - height: asset.height, - } - texture.flipY = true - texture.unpackAlignment = 1 - texture.needsUpdate = true + // Fetches assets via XMLHttpRequest + THREE.FileLoader.prototype.load = function load(url, onLoad, onProgress, onError) { + if (this.path) url = this.path + url - // Force non-DOM upload for EXGL fast paths - // @ts-ignore - texture.isDataTexture = true + const request = new XMLHttpRequest() - onLoad?.(texture) - }) - .catch(onError) + getAsset(url) + .then((asset) => { + request.open('GET', asset.uri, true) - return texture - } + request.addEventListener( + 'load', + (event) => { + if (request.status === 200) { + onLoad?.(request.response) - // Fetches assets via XMLHttpRequest - THREE.FileLoader.prototype.load = function load(url, onLoad, onProgress, onError) { - if (this.path) url = this.path + url + this.manager.itemEnd(url) + } else { + onError?.(event as unknown as ErrorEvent) - const request = new XMLHttpRequest() + this.manager.itemError(url) + this.manager.itemEnd(url) + } + }, + false, + ) - getAsset(url) - .then((asset) => { - request.open('GET', asset.uri, true) + request.addEventListener( + 'progress', + (event) => { + onProgress?.(event) + }, + false, + ) - request.addEventListener( - 'load', - (event) => { - if (request.status === 200) { - onLoad?.(request.response) + request.addEventListener( + 'error', + (event) => { + onError?.(event as unknown as ErrorEvent) + this.manager.itemError(url) this.manager.itemEnd(url) - } else { + }, + false, + ) + + request.addEventListener( + 'abort', + (event) => { onError?.(event as unknown as ErrorEvent) this.manager.itemError(url) this.manager.itemEnd(url) - } - }, - false, - ) - - request.addEventListener( - 'progress', - (event) => { - onProgress?.(event) - }, - false, - ) - - request.addEventListener( - 'error', - (event) => { - onError?.(event as unknown as ErrorEvent) - - this.manager.itemError(url) - this.manager.itemEnd(url) - }, - false, - ) - - request.addEventListener( - 'abort', - (event) => { - onError?.(event as unknown as ErrorEvent) - - this.manager.itemError(url) - this.manager.itemEnd(url) - }, - false, - ) - - if (this.responseType) request.responseType = this.responseType - if (this.withCredentials) request.withCredentials = this.withCredentials - - for (const header in this.requestHeader) { - request.setRequestHeader(header, this.requestHeader[header]) - } + }, + false, + ) - request.send(null) + if (this.responseType) request.responseType = this.responseType + if (this.withCredentials) request.withCredentials = this.withCredentials - this.manager.itemStart(url) - }) - .catch(onError) + for (const header in this.requestHeader) { + request.setRequestHeader(header, this.requestHeader[header]) + } + + request.send(null) - return request + this.manager.itemStart(url) + }) + .catch(onError) + + return request + } } } From e91192f97a7257e83e1cfcb2583daf6eeab74c6c Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 31 Aug 2023 20:37:45 -0500 Subject: [PATCH 10/19] experiment: polyfill as canvas-effect This should not be needed, Metro may be tree-shaking effectful code wrongly. --- packages/fiber/src/native/Canvas.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/fiber/src/native/Canvas.tsx b/packages/fiber/src/native/Canvas.tsx index 5361ed28ce..e46b71ea21 100644 --- a/packages/fiber/src/native/Canvas.tsx +++ b/packages/fiber/src/native/Canvas.tsx @@ -7,6 +7,7 @@ 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 @@ -66,6 +67,9 @@ const CanvasImpl = /*#__PURE__*/ React.forwardRef( const viewRef = React.useRef(null!) const root = React.useRef>(null!) + // Inject and cleanup RN polyfills if able + React.useMemo(() => polyfills(), []) + const onLayout = React.useCallback((e: LayoutChangeEvent) => { const { width, height, x, y } = e.nativeEvent.layout setSize({ width, height, top: y, left: x }) From a17b9260fb6370864b66652f3ad2c4822e64a8ea Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 31 Aug 2023 20:37:51 -0500 Subject: [PATCH 11/19] Revert "experiment: polyfill as canvas-effect" This reverts commit e91192f97a7257e83e1cfcb2583daf6eeab74c6c. --- packages/fiber/src/native/Canvas.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/fiber/src/native/Canvas.tsx b/packages/fiber/src/native/Canvas.tsx index e46b71ea21..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.useMemo(() => polyfills(), []) - const onLayout = React.useCallback((e: LayoutChangeEvent) => { const { width, height, x, y } = e.nativeEvent.layout setSize({ width, height, top: y, left: x }) From 2cdd86c7465fdf98102dd35c828a8dc3e957311e Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 1 Sep 2023 02:50:50 -0500 Subject: [PATCH 12/19] chore: cleanup polyfill for native-web compat --- packages/fiber/__mocks__/expo-asset.ts | 6 +- packages/fiber/package.json | 1 + packages/fiber/src/native/polyfills.ts | 351 +++++++++------------ packages/fiber/tests/native/hooks.test.tsx | 2 +- 4 files changed, 155 insertions(+), 205 deletions(-) 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/package.json b/packages/fiber/package.json index 445d66eff0..5a0d0acdd4 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", diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index 652085ad27..dd095eda32 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -1,241 +1,190 @@ import * as THREE from 'three' -import { Platform, NativeModules, Image } from 'react-native' +import { Image } from 'react-native' import { Asset } from 'expo-asset' import * as fs from 'expo-file-system' - -export function polyfills() { - if (Platform.OS !== 'web') { - const BlobManager = require('react-native/Libraries/Blob/BlobManager.js') - const { fromByteArray } = require('base64-js') - - 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) - }) - } - - const { BlobModule } = NativeModules - let BLOB_URL_PREFIX: string | null = null - - if (BlobModule && typeof BlobModule.BLOB_URI_SCHEME === 'string') { - BLOB_URL_PREFIX = BlobModule.BLOB_URI_SCHEME + ':' - if (typeof BlobModule.BLOB_URI_HOST === 'string') { - BLOB_URL_PREFIX += `//${BlobModule.BLOB_URI_HOST}/` - } - } - - BlobManager.createFromParts = function createFromParts(parts: Array, options: any) { - const blobId = uuidv4() - - const items = parts.map((part) => { - if (part instanceof ArrayBuffer || ArrayBuffer.isView(part)) { - const data = fromByteArray(new Uint8Array(part as ArrayBuffer)) - return { - data, - type: 'string', - } - } else if (part instanceof Blob) { - return { - data: (part as any).data, - type: 'blob', +import { fromByteArray } from 'base64-js' + +export async function polyfills() { + 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)) } - } else { - return { - data: String(part), - type: 'string', - } - } - }) - const size = items.reduce((acc, curr) => { - if (curr.type === 'string') { - return acc + global.unescape(encodeURI(curr.data)).length - } else { - return acc + curr.data.size - } - }, 0) - - BlobModule.createFromParts(items, blobId) - return BlobManager.createFromOptions({ - blobId, - offset: 0, - size, - type: options ? options.type : '', - lastModified: options ? options.lastModified : Date.now(), - }) - } - - URL.createObjectURL = function createObjectURL(blob) { - if (BLOB_URL_PREFIX === null) { - throw new Error('Cannot create URL for blob!') - } - // @ts-ignore - return `${BLOB_URL_PREFIX}${blob.data.blobId}?offset=${blob.data.offset}&size=${blob.size}` + return part + }), + options, + ) } + } - /** - * Generates an asset based on input type. - */ - async function getAsset(input: string | number): Promise { - if (typeof input === 'string') { - // 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}` - } + 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) + }) + } - // Create safe URI for JSI - if (input.startsWith('data:')) { - const [header, data] = input.split(',') - const [, type] = header.split('/') + async function getAsset(input: string | number): Promise { + if (typeof input === 'string') { + // 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 localUri = fs.cacheDirectory + uuidv4() + `.${type}` - await fs.writeAsStringAsync(localUri, data, { encoding: fs.EncodingType.Base64 }) + const data = await new Promise((res, rej) => { + const reader = new FileReader() + reader.onload = () => res(reader.result as string) + reader.onerror = rej + reader.readAsText(blob) + }) - return { localUri } as Asset - } + input = `data:${blob.type};base64,${data}` } - // Download bundler module or external URL - const asset = Asset.fromModule(input) + // 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 }) - // 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 - THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { - const texture = new THREE.Texture() - - getAsset(url) - .then(async (asset: Asset) => { - if (!asset.width || !asset.height) { - const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => - Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), - ) - asset.width = width - asset.height = height - } + // Download bundler module or external URL + const asset = Asset.fromModule(input) - texture.image = { - data: { localUri: asset.localUri }, - width: asset.width, - height: asset.height, - } - texture.flipY = true - texture.unpackAlignment = 1 - texture.needsUpdate = true + // 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 + } - // Force non-DOM upload for EXGL fast paths - // @ts-ignore - texture.isDataTexture = true + // Otherwise, resolve from registry + return asset.downloadAsync() + } - onLoad?.(texture) - }) - .catch(onError) + // 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) : './') - return texture - } + // There's no Image in native, so create a data texture instead + THREE.TextureLoader.prototype.load = function load(url, onLoad, onProgress, onError) { + const texture = new THREE.Texture() - // Fetches assets via XMLHttpRequest - THREE.FileLoader.prototype.load = function load(url, onLoad, onProgress, onError) { - if (this.path) url = this.path + url + getAsset(url) + .then(async (asset: Asset) => { + if (!asset.width || !asset.height) { + const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => + Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), + ) + asset.width = width + asset.height = height + } - const request = new XMLHttpRequest() + texture.image = { + data: { localUri: asset.localUri }, + width: asset.width, + height: asset.height, + } + texture.flipY = true + texture.unpackAlignment = 1 + texture.needsUpdate = true - getAsset(url) - .then((asset) => { - request.open('GET', asset.uri, true) + // Force non-DOM upload for EXGL fast paths + // @ts-ignore + texture.isDataTexture = true - request.addEventListener( - 'load', - (event) => { - if (request.status === 200) { - onLoad?.(request.response) + onLoad?.(texture) + }) + .catch(onError) - this.manager.itemEnd(url) - } else { - onError?.(event as unknown as ErrorEvent) + return texture + } - this.manager.itemError(url) - this.manager.itemEnd(url) - } - }, - false, - ) + // Fetches assets via XMLHttpRequest + THREE.FileLoader.prototype.load = function load(url, onLoad, onProgress, onError) { + if (this.path) url = this.path + url - request.addEventListener( - 'progress', - (event) => { - onProgress?.(event) - }, - false, - ) + const request = new XMLHttpRequest() - request.addEventListener( - 'error', - (event) => { - onError?.(event as unknown as ErrorEvent) + getAsset(url) + .then((asset) => { + request.open('GET', asset.uri, true) - this.manager.itemError(url) - this.manager.itemEnd(url) - }, - false, - ) + request.addEventListener( + 'load', + (event) => { + if (request.status === 200) { + onLoad?.(request.response) - request.addEventListener( - 'abort', - (event) => { + this.manager.itemEnd(url) + } else { onError?.(event as unknown as ErrorEvent) this.manager.itemError(url) this.manager.itemEnd(url) - }, - false, - ) - - if (this.responseType) request.responseType = this.responseType - if (this.withCredentials) request.withCredentials = this.withCredentials - - for (const header in this.requestHeader) { - request.setRequestHeader(header, this.requestHeader[header]) - } + } + }, + false, + ) + + request.addEventListener( + 'progress', + (event) => { + onProgress?.(event) + }, + false, + ) + + request.addEventListener( + 'error', + (event) => { + onError?.(event as unknown as ErrorEvent) + + this.manager.itemError(url) + this.manager.itemEnd(url) + }, + false, + ) + + request.addEventListener( + 'abort', + (event) => { + onError?.(event as unknown as ErrorEvent) + + this.manager.itemError(url) + this.manager.itemEnd(url) + }, + false, + ) + + if (this.responseType) request.responseType = this.responseType + if (this.withCredentials) request.withCredentials = this.withCredentials + + for (const header in this.requestHeader) { + request.setRequestHeader(header, this.requestHeader[header]) + } - request.send(null) + request.send(null) - this.manager.itemStart(url) - }) - .catch(onError) + this.manager.itemStart(url) + }) + .catch(onError) - return request - } + return request } } diff --git a/packages/fiber/tests/native/hooks.test.tsx b/packages/fiber/tests/native/hooks.test.tsx index d51c5bff7b..75e9c65221 100644 --- a/packages/fiber/tests/native/hooks.test.tsx +++ b/packages/fiber/tests/native/hooks.test.tsx @@ -26,7 +26,7 @@ describe('useLoader', () => { ) }) - it.skip('produces data textures for TextureLoader', async () => { + it('produces data textures for TextureLoader', async () => { let texture: any const Component = () => { From 0772cfaa8cee39a0c2224b152ae610471df68fed Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 1 Sep 2023 02:51:32 -0500 Subject: [PATCH 13/19] Update polyfills.ts --- packages/fiber/src/native/polyfills.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index dd095eda32..ee15603f01 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -4,7 +4,7 @@ import { Asset } from 'expo-asset' import * as fs from 'expo-file-system' import { fromByteArray } from 'base64-js' -export async function polyfills() { +export function polyfills() { global.Blob = class extends Blob { constructor(parts?: any[], options?: any) { super( From dd4b3006910420e883c6145941d43bf3f1e4c2b6 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 3 Sep 2023 21:45:49 -0500 Subject: [PATCH 14/19] fix: don't process storage --- packages/fiber/src/native/polyfills.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index 4062cfe489..c93370c19e 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -30,6 +30,9 @@ export function polyfills() { 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) => { From 066d0e93ef0469d669149068463e3958eeac54ea Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 3 Sep 2023 22:47:10 -0500 Subject: [PATCH 15/19] fix: unwrap fs paths in FileLoader --- packages/fiber/src/native/polyfills.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index c93370c19e..afdce15bc7 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -127,8 +127,16 @@ export function polyfills() { const request = new XMLHttpRequest() getAsset(url) - .then((asset) => { - request.open('GET', asset.uri, true) + .then(async (asset) => { + let uri = 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', From f413499d8658b570e2266f397f113c38fb19f6c9 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 3 Sep 2023 22:49:45 -0500 Subject: [PATCH 16/19] fix: refer localUri --- packages/fiber/src/native/polyfills.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index afdce15bc7..1146de348b 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -92,16 +92,18 @@ export function polyfills() { getAsset(url) .then(async (asset: Asset) => { + let uri = asset.localUri || asset.uri + if (!asset.width || !asset.height) { const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => - Image.getSize(asset.localUri!, (width, height) => res({ width, height }), rej), + Image.getSize(uri, (width, height) => res({ width, height }), rej), ) asset.width = width asset.height = height } texture.image = { - data: { localUri: asset.localUri }, + data: { localUri: uri }, width: asset.width, height: asset.height, } @@ -128,7 +130,7 @@ export function polyfills() { getAsset(url) .then(async (asset) => { - let uri = asset.uri + let uri = asset.localUri || asset.uri // Make FS paths web-safe if (asset.uri.startsWith('file://')) { From 590d2b46c0ab7373760d88678478a0d64b232b77 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 3 Sep 2023 22:50:03 -0500 Subject: [PATCH 17/19] chore: cleanup --- packages/fiber/src/native/polyfills.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index 1146de348b..e0a3cdeed6 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -92,7 +92,7 @@ export function polyfills() { getAsset(url) .then(async (asset: Asset) => { - let uri = asset.localUri || asset.uri + const uri = asset.localUri || asset.uri if (!asset.width || !asset.height) { const { width, height } = await new Promise<{ width: number; height: number }>((res, rej) => From a002b6d58dde6f4b7357cc913c00542fec76c526 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 4 Sep 2023 03:29:39 -0500 Subject: [PATCH 18/19] fix: lazily patch Blob --- packages/fiber/src/native/polyfills.ts | 28 +++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index e0a3cdeed6..99e9194e44 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -5,18 +5,22 @@ import * as fs from 'expo-file-system' import { fromByteArray } from 'base64-js' export function polyfills() { - 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, - ) + try { + new Blob([new ArrayBuffer(4)]) + } 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, + ) + } } } From 450774f5aa5998cfcd3b8c92a0879a1ffbf0b7e4 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 4 Sep 2023 03:33:33 -0500 Subject: [PATCH 19/19] chore: lint --- packages/fiber/src/native/polyfills.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/fiber/src/native/polyfills.ts b/packages/fiber/src/native/polyfills.ts index 99e9194e44..cfd41ac065 100644 --- a/packages/fiber/src/native/polyfills.ts +++ b/packages/fiber/src/native/polyfills.ts @@ -5,8 +5,9 @@ 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)]) + new Blob([new ArrayBuffer(4) as any]) } catch (_) { global.Blob = class extends Blob { constructor(parts?: any[], options?: any) {