diff --git a/apps/tester-expo-widget/.env.development b/apps/tester-expo-widget/.env.development new file mode 100644 index 000000000..277004f69 --- /dev/null +++ b/apps/tester-expo-widget/.env.development @@ -0,0 +1 @@ +EXPO_PUBLIC_FEDERATION_OWNER=expo-widget diff --git a/apps/tester-expo-widget/.env.production b/apps/tester-expo-widget/.env.production new file mode 100644 index 000000000..277004f69 --- /dev/null +++ b/apps/tester-expo-widget/.env.production @@ -0,0 +1 @@ +EXPO_PUBLIC_FEDERATION_OWNER=expo-widget diff --git a/apps/tester-expo-widget/.gitignore b/apps/tester-expo-widget/.gitignore new file mode 100644 index 000000000..721fe0e61 --- /dev/null +++ b/apps/tester-expo-widget/.gitignore @@ -0,0 +1,21 @@ +# Native projects are generated by Expo prebuild and intentionally untracked. +/android +/ios + +.DS_Store +build/ +node_modules/ +.expo/ +*.jsbundle +*.map + +**/Pods/ +**/.gradle/ +**/DerivedData/ +**/xcuserdata/ +**/.xcode.env.local +local.properties +*.keystore +!debug.keystore + +**/@mf-types diff --git a/apps/tester-expo-widget/app.json b/apps/tester-expo-widget/app.json new file mode 100644 index 000000000..6a3cef592 --- /dev/null +++ b/apps/tester-expo-widget/app.json @@ -0,0 +1,18 @@ +{ + "expo": { + "name": "Repack Expo Widget", + "slug": "repack-expo-widget", + "scheme": "repackexpowidget", + "version": "1.0.0", + "updates": { + "enabled": false + }, + "plugins": ["@callstack/repack-expo"], + "ios": { + "bundleIdentifier": "com.callstack.repackexpowidget" + }, + "android": { + "package": "com.callstack.repackexpowidget" + } + } +} diff --git a/apps/tester-expo-widget/assets/README.md b/apps/tester-expo-widget/assets/README.md new file mode 100644 index 000000000..c1bddaa59 --- /dev/null +++ b/apps/tester-expo-widget/assets/README.md @@ -0,0 +1,7 @@ +# Widget fixture assets + +The density images are copied from the repository's Expo host fixture. The +Material Symbols font is copied from the same fixture and is distributed under +the Apache License 2.0 in `fonts/LICENSE-MATERIAL-SYMBOLS`. + +These copies keep the independently published widget artifact self-contained. diff --git a/apps/tester-expo-widget/assets/fonts/LICENSE-MATERIAL-SYMBOLS b/apps/tester-expo-widget/assets/fonts/LICENSE-MATERIAL-SYMBOLS new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/apps/tester-expo-widget/assets/fonts/LICENSE-MATERIAL-SYMBOLS @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/apps/tester-expo-widget/assets/fonts/material-symbols-regular.ttf b/apps/tester-expo-widget/assets/fonts/material-symbols-regular.ttf new file mode 100644 index 000000000..190afc39c Binary files /dev/null and b/apps/tester-expo-widget/assets/fonts/material-symbols-regular.ttf differ diff --git a/apps/tester-expo-widget/assets/images/widget-density.png b/apps/tester-expo-widget/assets/images/widget-density.png new file mode 100644 index 000000000..352e4631e Binary files /dev/null and b/apps/tester-expo-widget/assets/images/widget-density.png differ diff --git a/apps/tester-expo-widget/assets/images/widget-density@2x.png b/apps/tester-expo-widget/assets/images/widget-density@2x.png new file mode 100644 index 000000000..23b99fc52 Binary files /dev/null and b/apps/tester-expo-widget/assets/images/widget-density@2x.png differ diff --git a/apps/tester-expo-widget/assets/images/widget-density@3x.png b/apps/tester-expo-widget/assets/images/widget-density@3x.png new file mode 100644 index 000000000..3719056cc Binary files /dev/null and b/apps/tester-expo-widget/assets/images/widget-density@3x.png differ diff --git a/apps/tester-expo-widget/babel.config.cjs b/apps/tester-expo-widget/babel.config.cjs new file mode 100644 index 000000000..e135c946f --- /dev/null +++ b/apps/tester-expo-widget/babel.config.cjs @@ -0,0 +1,6 @@ +module.exports = function babelConfig(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/apps/tester-expo-widget/expo-env.d.ts b/apps/tester-expo-widget/expo-env.d.ts new file mode 100644 index 000000000..e6394b247 --- /dev/null +++ b/apps/tester-expo-widget/expo-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/tester-expo-widget/index.ts b/apps/tester-expo-widget/index.ts new file mode 100644 index 000000000..c1c862e92 --- /dev/null +++ b/apps/tester-expo-widget/index.ts @@ -0,0 +1,4 @@ +import { registerRootComponent } from 'expo'; +import App from './src/App'; + +registerRootComponent(App); diff --git a/apps/tester-expo-widget/package.json b/apps/tester-expo-widget/package.json new file mode 100644 index 000000000..1953441f6 --- /dev/null +++ b/apps/tester-expo-widget/package.json @@ -0,0 +1,39 @@ +{ + "name": "tester-expo-widget", + "version": "0.0.1", + "private": true, + "main": "index.ts", + "scripts": { + "repack:start": "react-native webpack-start", + "repack:start:remote": "react-native webpack-start --port 8082", + "repack:ios": "expo run:ios --no-bundler", + "repack:android": "expo run:android --no-bundler", + "repack:build:production": "pnpm run repack:build:production:ios && pnpm run repack:build:production:android", + "repack:build:production:ios": "node ./scripts/build-production.mjs ios", + "repack:build:production:android": "node ./scripts/build-production.mjs android", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@callstack/repack": "workspace:*", + "@callstack/repack-expo": "workspace:*", + "@module-federation/enhanced": "2.1.0", + "expo": "~57.0.9", + "expo-asset": "~57.0.8", + "expo-constants": "~57.0.8", + "expo-font": "~57.0.1", + "react": "19.2.3", + "react-native": "0.86.2" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@react-native-community/cli": "20.1.2", + "@react-native-community/cli-platform-android": "20.1.2", + "@react-native-community/cli-platform-ios": "20.1.2", + "@react-native/babel-preset": "0.86.2", + "@react-native/community-cli-plugin": "0.86.2", + "@rspack/core": "catalog:", + "@types/react": "~19.2.2", + "babel-preset-expo": "~57.0.5", + "typescript": "~6.0.3" + } +} diff --git a/apps/tester-expo-widget/react-native.config.js b/apps/tester-expo-widget/react-native.config.js new file mode 100644 index 000000000..d3fe7ef96 --- /dev/null +++ b/apps/tester-expo-widget/react-native.config.js @@ -0,0 +1,3 @@ +module.exports = { + commands: require('@callstack/repack/commands/rspack'), +}; diff --git a/apps/tester-expo-widget/rspack.config.mjs b/apps/tester-expo-widget/rspack.config.mjs new file mode 100644 index 000000000..e0739c7dd --- /dev/null +++ b/apps/tester-expo-widget/rspack.config.mjs @@ -0,0 +1,94 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as Repack from '@callstack/repack'; +import { ExpoPlugin } from '@callstack/repack-expo/rspack'; + +const projectRoot = path.dirname(fileURLToPath(import.meta.url)); + +export default (env) => { + const { + mode = 'development', + platform = process.env.PLATFORM, + devServer, + } = env; + + if (platform !== 'ios' && platform !== 'android') { + throw new Error( + 'tester-expo-widget requires PLATFORM=ios or PLATFORM=android' + ); + } + + const remoteOutputPath = path.join(projectRoot, 'build', 'remote', platform); + const remotePublicPath = `http://localhost:8082/${platform}/remote-assets`; + + return { + context: projectRoot, + devServer, + devtool: 'source-map', + mode, + name: platform, + module: { + rules: Repack.getAssetTransformRules({ + remote: { publicPath: remotePublicPath }, + }), + }, + output: { + clean: true, + path: path.join(projectRoot, 'build', 'rspack', platform), + publicPath: `http://localhost:8082/${platform}/`, + uniqueName: `tester-expo-widget-${platform}`, + }, + plugins: [ + new ExpoPlugin({ + platform, + repack: { + output: { + auxiliaryAssetsPath: remoteOutputPath, + }, + extraChunks: [ + { + include: /.*/, + type: 'remote', + outputPath: remoteOutputPath, + }, + ], + }, + }), + new Repack.plugins.ModuleFederationPluginV2({ + name: 'ExpoWidget', + filename: 'ExpoWidget.container.js.bundle', + exposes: { + './Widget': './src/Widget', + }, + dts: false, + shared: { + react: { + singleton: true, + eager: true, + requiredVersion: '19.2.3', + }, + 'react-native': { + singleton: true, + eager: true, + requiredVersion: '0.86.2', + }, + 'expo-constants': { + singleton: true, + eager: true, + requiredVersion: '~57.0.8', + }, + 'expo-asset': { + singleton: true, + eager: true, + requiredVersion: '~57.0.8', + }, + 'expo-font': { + singleton: true, + eager: true, + requiredVersion: '~57.0.1', + }, + }, + }), + ], + }; +}; diff --git a/apps/tester-expo-widget/scripts/build-production.mjs b/apps/tester-expo-widget/scripts/build-production.mjs new file mode 100644 index 000000000..a417ab136 --- /dev/null +++ b/apps/tester-expo-widget/scripts/build-production.mjs @@ -0,0 +1,168 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const FEDERATION_METADATA_FILES = ['mf-manifest.json', 'mf-stats.json']; +const supportedPlatforms = new Set(['android', 'ios']); + +function assertProductionPlatform(platform) { + if (!supportedPlatforms.has(platform)) { + throw new Error( + `Expected production widget platform to be ios or android, received ${JSON.stringify(platform)}` + ); + } + return platform; +} + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +export const defaultProjectRoot = path.resolve(scriptDirectory, '..'); + +export function resolveProductionArtifactPaths(projectRoot, platform) { + assertProductionPlatform(platform); + const standaloneRoot = path.join( + projectRoot, + 'build', + 'standalone', + platform + ); + const bundleFilename = + platform === 'ios' ? 'main.jsbundle' : 'index.android.bundle'; + + return { + assetsDest: + platform === 'ios' ? standaloneRoot : path.join(standaloneRoot, 'res'), + bundleOutput: path.join(standaloneRoot, bundleFilename), + configPath: path.join(projectRoot, 'rspack.config.mjs'), + entryFile: path.join(projectRoot, 'index.ts'), + publishRoot: path.join(projectRoot, 'build', 'remote', platform), + sourceMapOutput: path.join(standaloneRoot, `${bundleFilename}.map`), + stagingRoot: path.join(projectRoot, 'build', 'rspack', platform), + standaloneRoot, + }; +} + +export function createBundleInvocation({ + commandName = 'bundle', + platform, + projectRoot = defaultProjectRoot, +}) { + if (commandName !== 'bundle' && commandName !== 'webpack-bundle') { + throw new Error( + `Expected Re.Pack production command to be bundle or webpack-bundle, received ${JSON.stringify(commandName)}` + ); + } + const paths = resolveProductionArtifactPaths(projectRoot, platform); + return { + arguments: [ + commandName, + '--config', + paths.configPath, + '--platform', + platform, + '--entry-file', + paths.entryFile, + '--dev=false', + '--bundle-output', + paths.bundleOutput, + '--sourcemap-output', + paths.sourceMapOutput, + '--assets-dest', + paths.assetsDest, + ], + command: 'react-native', + cwd: projectRoot, + paths, + }; +} + +export function createFederationMetadataCopyPlan({ publishRoot, stagingRoot }) { + return FEDERATION_METADATA_FILES.map((filename) => ({ + destination: path.join(publishRoot, filename), + filename, + source: path.join(stagingRoot, filename), + })); +} + +export function publishFederationMetadata( + paths, + { copyFileSync = fs.copyFileSync, mkdirSync = fs.mkdirSync } = {} +) { + const plan = createFederationMetadataCopyPlan(paths); + mkdirSync(paths.publishRoot, { recursive: true }); + for (const item of plan) { + copyFileSync(item.source, item.destination); + } + return plan; +} + +function renderInvocation(invocation) { + return [invocation.command, ...invocation.arguments] + .map((part) => (part.includes(' ') ? JSON.stringify(part) : part)) + .join(' '); +} + +export function runProductionBuild({ + commandName = 'bundle', + platform, + projectRoot = defaultProjectRoot, + spawn = spawnSync, +}) { + const invocation = createBundleInvocation({ + commandName, + platform, + projectRoot, + }); + + fs.rmSync(invocation.paths.publishRoot, { force: true, recursive: true }); + fs.rmSync(invocation.paths.standaloneRoot, { force: true, recursive: true }); + fs.mkdirSync(invocation.paths.standaloneRoot, { recursive: true }); + + console.log(`Running ${renderInvocation(invocation)}`); + const result = spawn(invocation.command, invocation.arguments, { + cwd: invocation.cwd, + env: process.env, + stdio: 'inherit', + }); + if (result.error) { + throw new Error( + `Could not run the standard Re.Pack ${commandName} command: ${result.error.message}` + ); + } + if (result.status !== 0) { + throw new Error( + `Standard Re.Pack ${commandName} command exited with status ${String(result.status)}` + ); + } + + const copiedMetadata = publishFederationMetadata(invocation.paths); + return { copiedMetadata, invocation }; +} + +function parseArguments(arguments_) { + const commandName = arguments_.includes('--webpack-bundle') + ? 'webpack-bundle' + : 'bundle'; + const positionals = arguments_.filter( + (argument) => argument !== '--webpack-bundle' + ); + if (positionals.length !== 1) { + throw new Error( + 'Usage: node scripts/build-production.mjs [--webpack-bundle]' + ); + } + return { commandName, platform: assertProductionPlatform(positionals[0]) }; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const options = parseArguments(process.argv.slice(2)); + const result = runProductionBuild(options); + console.log( + `Published ${options.platform} Expo widget artifacts to ${result.invocation.paths.publishRoot}.` + ); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/apps/tester-expo-widget/src/App.tsx b/apps/tester-expo-widget/src/App.tsx new file mode 100644 index 000000000..2107d411a --- /dev/null +++ b/apps/tester-expo-widget/src/App.tsx @@ -0,0 +1,18 @@ +import { SafeAreaView, StyleSheet } from 'react-native'; +import Widget from './Widget'; + +export default function App() { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + }, +}); diff --git a/apps/tester-expo-widget/src/Widget.tsx b/apps/tester-expo-widget/src/Widget.tsx new file mode 100644 index 000000000..46262867e --- /dev/null +++ b/apps/tester-expo-widget/src/Widget.tsx @@ -0,0 +1,160 @@ +import { Asset } from 'expo-asset'; +import Constants from 'expo-constants'; +import { useFonts } from 'expo-font'; +import { + type ComponentType, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { Image, PixelRatio, StyleSheet, Text, View } from 'react-native'; + +const widgetDensityImage = require('../assets/images/widget-density.png'); +const widgetDensityAsset = Asset.fromModule(widgetDensityImage); +const widgetFontAsset = Asset.fromModule( + require('../assets/fonts/material-symbols-regular.ttf') +); +const widgetRuntimeGeneration = Date.now(); + +type LazyDetailsComponent = ComponentType<{ onReady: () => void }>; + +type LazyDetailsLoadState = + | { status: 'loading' } + | { component: LazyDetailsComponent; status: 'loaded' } + | { error: Error; status: 'failed' }; + +function LazyDetailsLoader({ + onReady, +}: { + onReady: () => void; +}) { + const [loadState, setLoadState] = useState({ + status: 'loading', + }); + + useEffect(() => { + let active = true; + import( + /* webpackChunkName: "expo-widget-lazy" */ './WidgetLazyDetails' + ).then( + ({ default: component }) => { + if (active) setLoadState({ component, status: 'loaded' }); + }, + (error: Error) => { + if (active) setLoadState({ error, status: 'failed' }); + } + ); + return () => { + active = false; + }; + }, []); + + if (loadState.status === 'failed') throw loadState.error; + if (loadState.status === 'loading') { + return ( + + Loading nested widget chunk… + + ); + } + + const LazyDetails = loadState.component; + return ; +} + +export default function Widget({ + recoveryAttempt = 0, +}: { + recoveryAttempt?: number; +}) { + const resolvedImage = useMemo( + () => Image.resolveAssetSource(widgetDensityImage), + [] + ); + const imageSource = useMemo(() => { + const separator = resolvedImage.uri.includes('?') ? '&' : '?'; + return { + ...resolvedImage, + uri: `${resolvedImage.uri}${separator}runtimeGeneration=${widgetRuntimeGeneration}&recoveryAttempt=${recoveryAttempt}`, + }; + }, [recoveryAttempt, resolvedImage]); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(); + const [lazyLoaded, setLazyLoaded] = useState(false); + const [fontsLoaded, fontError] = useFonts({ + ExpoWidgetFixture: widgetFontAsset, + }); + const handleLazyReady = useCallback(() => setLazyLoaded(true), []); + const ready = imageLoaded && fontsLoaded && lazyLoaded; + + if (imageError) throw imageError; + if (fontError) throw fontError; + + return ( + + {ready ? ( + Expo widget resources loaded + ) : null} + Expo MF v2 widget loaded by Re.Pack. + + Native app owner: {Constants.expoConfig?.name ?? 'host'} + + + Widget public environment:{' '} + {process.env.EXPO_PUBLIC_FEDERATION_OWNER ?? 'missing'} + + + Widget asset type: {widgetDensityAsset.type} + + {imageLoaded ? ( + + Widget density image loaded + + ) : null} + + setImageError(new Error(nativeEvent.error)) + } + onLoad={() => setImageLoaded(true)} + source={imageSource} + style={styles.image} + testID="expo-widget-density-image" + /> + + Selected widget density scale: {resolvedImage.scale} + + + Widget device density scale: {PixelRatio.get()} + + {fontsLoaded ? ( + + home + + ) : ( + Loading widget font… + )} + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#fff7ed', + borderColor: '#c2410c', + borderRadius: 8, + borderWidth: 1, + gap: 8, + padding: 16, + }, + fixtureFont: { + fontFamily: 'ExpoWidgetFixture', + fontSize: 32, + }, + image: { + height: 48, + width: 48, + }, +}); diff --git a/apps/tester-expo-widget/src/WidgetLazyDetails.tsx b/apps/tester-expo-widget/src/WidgetLazyDetails.tsx new file mode 100644 index 000000000..e8f28a289 --- /dev/null +++ b/apps/tester-expo-widget/src/WidgetLazyDetails.tsx @@ -0,0 +1,30 @@ +import { StyleSheet, Text, View } from 'react-native'; + +const lazyChunkSentinel = 'repack-expo-widget-lazy-sentinel'; + +export default function WidgetLazyDetails({ + onReady, +}: { + onReady: () => void; +}) { + return ( + + This content was loaded from a nested remote widget chunk. + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#eff6ff', + borderColor: '#1d4ed8', + borderRadius: 8, + borderWidth: 1, + padding: 12, + }, +}); diff --git a/apps/tester-expo-widget/tsconfig.json b/apps/tester-expo-widget/tsconfig.json new file mode 100644 index 000000000..5bfb77848 --- /dev/null +++ b/apps/tester-expo-widget/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + }, + "include": ["*.ts", "*.tsx", "src/**/*.ts", "src/**/*.tsx", "expo-env.d.ts"] +} diff --git a/apps/tester-expo/.env.development b/apps/tester-expo/.env.development new file mode 100644 index 000000000..751c8bfb9 --- /dev/null +++ b/apps/tester-expo/.env.development @@ -0,0 +1 @@ +EXPO_PUBLIC_FEDERATION_OWNER=expo-host diff --git a/apps/tester-expo/.env.production b/apps/tester-expo/.env.production new file mode 100644 index 000000000..751c8bfb9 --- /dev/null +++ b/apps/tester-expo/.env.production @@ -0,0 +1 @@ +EXPO_PUBLIC_FEDERATION_OWNER=expo-host diff --git a/apps/tester-expo/.gitignore b/apps/tester-expo/.gitignore new file mode 100644 index 000000000..a486fe0a3 --- /dev/null +++ b/apps/tester-expo/.gitignore @@ -0,0 +1,3 @@ +# Native projects are generated by Expo prebuild and intentionally untracked. +/android +/ios diff --git a/apps/tester-expo/app.json b/apps/tester-expo/app.json new file mode 100644 index 000000000..e9fdeb8fc --- /dev/null +++ b/apps/tester-expo/app.json @@ -0,0 +1,29 @@ +{ + "expo": { + "name": "Repack Expo Tester", + "slug": "repack-expo-tester", + "scheme": "repackexpo", + "version": "1.0.0", + "orientation": "portrait", + "updates": { + "enabled": false + }, + "plugins": [ + "expo-router", + "@callstack/repack-expo", + "./plugins/with-localhost-cleartext" + ], + "ios": { + "bundleIdentifier": "com.callstack.repackexpo" + }, + "android": { + "package": "com.callstack.repackexpo" + }, + "extra": { + "eas": { + "projectId": "49a167cf-9412-446e-ba71-0c07c816ed93" + } + }, + "owner": "wuskas" + } +} diff --git a/apps/tester-expo/app/_layout.tsx b/apps/tester-expo/app/_layout.tsx new file mode 100644 index 000000000..624cfe6c7 --- /dev/null +++ b/apps/tester-expo/app/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function RootLayout() { + return ; +} diff --git a/apps/tester-expo/app/density-image.tsx b/apps/tester-expo/app/density-image.tsx new file mode 100644 index 000000000..a3d0ee7cd --- /dev/null +++ b/apps/tester-expo/app/density-image.tsx @@ -0,0 +1,55 @@ +import { Link } from 'expo-router'; +import { useState } from 'react'; +import { Image, PixelRatio, StyleSheet, Text, View } from 'react-native'; + +const densityImage = require('../assets/images/density.png'); + +export default function DensityImageRoute() { + const resolvedImage = Image.resolveAssetSource(densityImage); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + + return ( + + {imageLoaded && !imageError ? ( + + Density-aware bundled image loaded + + ) : null} + {imageError ? ( + + Density-aware bundled image failed to load + + ) : null} + setImageError(true)} + onLoad={() => setImageLoaded(true)} + source={densityImage} + style={styles.image} + testID="density-image" + /> + + Selected density scale: {resolvedImage.scale} + + + Device density scale: {PixelRatio.get()} + + Back home + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + gap: 16, + justifyContent: 'center', + padding: 24, + }, + image: { + height: 48, + width: 48, + }, +}); diff --git a/apps/tester-expo/app/details.tsx b/apps/tester-expo/app/details.tsx new file mode 100644 index 000000000..ddc776e6a --- /dev/null +++ b/apps/tester-expo/app/details.tsx @@ -0,0 +1,24 @@ +import Constants from 'expo-constants'; +import { Link } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function DetailsRoute() { + return ( + + + Expo runtime: {Constants.expoVersion ?? 'development build'} + + Back home + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + gap: 16, + justifyContent: 'center', + padding: 24, + }, +}); diff --git a/apps/tester-expo/app/font.tsx b/apps/tester-expo/app/font.tsx new file mode 100644 index 000000000..08fe4d71a --- /dev/null +++ b/apps/tester-expo/app/font.tsx @@ -0,0 +1,49 @@ +import { useFonts } from 'expo-font'; +import { Link } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function FontRoute() { + const [fontsLoaded, fontError] = useFonts({ + ExpoFixture: require('../assets/fonts/material-symbols-regular.ttf'), + }); + + if (fontError) { + return ( + + Font error: {fontError.message} + + ); + } + + if (!fontsLoaded) { + return ( + + Loading bundled font… + + ); + } + + return ( + + Bundled font loaded by expo-font: + + home + + Back home + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + gap: 16, + justifyContent: 'center', + padding: 24, + }, + fixtureFont: { + fontFamily: 'ExpoFixture', + fontSize: 32, + }, +}); diff --git a/apps/tester-expo/app/image.tsx b/apps/tester-expo/app/image.tsx new file mode 100644 index 000000000..122c16af6 --- /dev/null +++ b/apps/tester-expo/app/image.tsx @@ -0,0 +1,68 @@ +import { Asset } from 'expo-asset'; +import { Link } from 'expo-router'; +import { useState } from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; + +const packagedImage = require('../assets/images/repack.png'); +const packagedAsset = Asset.fromModule(packagedImage); +const monorepoImage = require('../../../tests/integration/src/loaders/__fixtures__/assets/logo.png'); +const monorepoAsset = Asset.fromModule(monorepoImage); + +export default function ImageRoute() { + const [appLocalImageLoaded, setAppLocalImageLoaded] = useState(false); + const [monorepoImageLoaded, setMonorepoImageLoaded] = useState(false); + const [imageError, setImageError] = useState(); + const imagesLoaded = + appLocalImageLoaded && monorepoImageLoaded && !imageError; + + return ( + + {imagesLoaded ? ( + + Expo assets loaded: {packagedAsset.name}.{packagedAsset.type} and{' '} + monorepo {monorepoAsset.name}.{monorepoAsset.type} + + ) : null} + {imageError ? {imageError} : null} + + setImageError((error) => error ?? 'App-local image failed to load') + } + onLoad={() => setAppLocalImageLoaded(true)} + source={packagedImage} + style={styles.logo} + testID="app-local-image" + /> + + setImageError((error) => error ?? 'Monorepo image failed to load') + } + onLoad={() => setMonorepoImageLoaded(true)} + source={monorepoImage} + style={styles.monorepoLogo} + testID="monorepo-image" + /> + Back home + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + gap: 16, + justifyContent: 'center', + padding: 24, + }, + logo: { + height: 30, + width: 147, + }, + monorepoLogo: { + height: 64, + width: 64, + }, +}); diff --git a/apps/tester-expo/app/index.tsx b/apps/tester-expo/app/index.tsx new file mode 100644 index 000000000..8e5a32427 --- /dev/null +++ b/apps/tester-expo/app/index.tsx @@ -0,0 +1,42 @@ +import { Link } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { useState } from 'react'; +import { Button, StyleSheet, Text, View } from 'react-native'; +import FastRefreshLeaf from '../components/FastRefreshLeaf'; +import SymbolicationProbe from '../components/SymbolicationProbe'; + +export default function HomeRoute() { + const [count, setCount] = useState(0); + + return ( + + Expo Router compiled by Re.Pack + Parent state: {count} +