Skip to content

Commit

Permalink
refactor(snack-runtime): upgrade custom runtime to Expo SDK 50 (#576)
Browse files Browse the repository at this point in the history
* refactor(snack-runtime): upgrade to Expo SDK `50.0.17`

* refactor(runtime-shell): upgrade to Expo SDK `50.0.17`

* refactor(runtime-shell): drop `useClassicUpdates: true` from `app.json`

* refactor(runtime-shell): drop `app.config.js` in favor of `.env` files

* refactor(snack-runtime): use new URL handling from `snack-content`

* refactor(snack-runtime): sync aliases and babel config from `runtime`

* refactor(runtime-shell): update to latest `snack-runtime`

* chore(snack-runtime): bump version for release

* docs: fix typo in comment

* refactor(snack-runtime): drop legacy test
  • Loading branch information
byCedric authored Apr 25, 2024
1 parent d710075 commit 35bf417
Show file tree
Hide file tree
Showing 20 changed files with 2,557 additions and 2,361 deletions.
3 changes: 2 additions & 1 deletion packages/snack-runtime/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { createRuntimeUrl, parseRuntimeUrl } from 'snack-content';

export * from './src/utils/ExpoApi';
export * from './src/utils/SnackAssets';
export * from './src/utils/SnackUrls';

export { default as SnackRuntime, type SnackState } from './src/App';
export { SnackRuntimeProvider, type SnackConfig } from './src/config/SnackConfig';
Expand Down
61 changes: 31 additions & 30 deletions packages/snack-runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "snack-runtime",
"version": "0.4.0",
"version": "0.5.0",
"description": "Load and run Expo Snacks in any React Native app",
"main": "index.ts",
"files": [
Expand Down Expand Up @@ -29,14 +29,15 @@
},
"dependencies": {
"@babel/polyfill": "^7.8.3",
"@react-native-async-storage/async-storage": "1.18.2",
"@react-native-async-storage/async-storage": "1.21.0",
"await-lock": "^2.2.2",
"canvaskit-wasm": "0.38.0",
"diff": "^5.0.0",
"escape-string-regexp": "^5.0.0",
"path": "^0.12.7",
"pubnub": "^7.2.0",
"snack-babel-standalone": "^3.0.0",
"snack-babel-standalone": "^4.0.0",
"snack-content": "^2.0.0",
"snack-require-context": "^0.1.0",
"socket.io-client": "~4.5.4",
"source-map": "0.6.1"
Expand All @@ -45,43 +46,43 @@
"@babel/core": "^7.22.20",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/react-native": "^12.2.2",
"babel-preset-expo": "^9.3.0",
"babel-preset-expo": "^10.0.0",
"eslint": "^8.49.0",
"eslint-config-universe": "^12.0.0",
"expo": "^49.0.10",
"expo-asset": "~8.10.1",
"expo-barcode-scanner": "~12.5.3",
"expo-constants": "~14.4.2",
"expo-file-system": "~15.4.4",
"expo-keep-awake": "~12.3.0",
"expo-random": "~13.2.0",
"expo-splash-screen": "~0.20.5",
"expo-status-bar": "~1.6.0",
"expo": "^50.0.17",
"expo-asset": "~9.0.2",
"expo-barcode-scanner": "~12.9.3",
"expo-constants": "~15.4.6",
"expo-file-system": "~16.0.9",
"expo-keep-awake": "~12.8.2",
"expo-random": "~13.6.0",
"expo-splash-screen": "~0.26.4",
"expo-status-bar": "~1.11.1",
"jest": "^29.2.1",
"jest-expo": "^49.0.0",
"jest-expo": "~50.0.4",
"prettier": "^3.0.3",
"react": "18.2.0",
"react-native": "0.72.4",
"react-native-gesture-handler": "~2.12.0",
"react-native-view-shot": "3.7.0",
"react-native": "0.73.6",
"react-native-gesture-handler": "~2.14.0",
"react-native-view-shot": "3.8.0",
"react-test-renderer": "18.2.0",
"typescript": "^4.6.3"
"typescript": "^5.3.0"
},
"peerDependencies": {
"assert": "^2.0.0",
"expo": "^49.0.0",
"expo-asset": "~8.10.0",
"expo-barcode-scanner": "~12.5.0",
"expo-constants": "~14.4.0",
"expo-file-system": "~15.4.0",
"expo-keep-awake": "~12.3.0",
"expo-random": "~13.2.0",
"expo-splash-screen": "~0.20.0",
"expo-status-bar": "~1.6.0",
"expo": "^50.0.0",
"expo-asset": "~9.0.0",
"expo-barcode-scanner": "~12.9.0",
"expo-constants": "~15.4.0",
"expo-file-system": "~16.0.0",
"expo-keep-awake": "~12.8.0",
"expo-random": "~13.6.0",
"expo-splash-screen": "~0.26.0",
"expo-status-bar": "~1.11.0",
"react": "~18.2.0",
"react-native": "~0.72.4",
"react-native-gesture-handler": "~2.12.0",
"react-native-view-shot": "~3.7.0"
"react-native": "~0.73.6",
"react-native-gesture-handler": "~2.14.0",
"react-native-view-shot": "~3.8.0"
},
"eslintConfig": {
"extends": "universe/native",
Expand Down
40 changes: 23 additions & 17 deletions packages/snack-runtime/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
EmitterSubscription,
NativeEventSubscription,
} from 'react-native';
import { parseRuntimeUrl } from 'snack-content';
import { createVirtualModulePath } from 'snack-require-context';

import { AppLoading } from './AppLoading';
Expand All @@ -31,15 +32,11 @@ import { captureRef as takeSnapshotAsync } from './NativeModules/ViewShot';
import getDeviceIdAsync from './NativeModules/getDeviceIdAsync';
import * as Profiling from './Profiling';
import UpdateIndicator from './UpdateIndicator';
import { parseTestTransportFromUrl } from './UrlUtils';
import { SnackRuntimeContext } from './config/SnackConfig';
import { type SnackApiCode, fetchCodeBySnackIdentifier } from './utils/ExpoApi';
import {
extractChannelFromSnackUrl,
extractSnackIdentifierFromSnackUrl,
parseExperienceURL,
} from './utils/SnackUrls';
import { type SnackApiCode, fetchCodeBySnackIdentifier, SnackApiError } from './utils/ExpoApi';

export type SnackState = 'loading' | 'finished' | 'error';
export type SnackState = 'loading' | 'finished' | 'not-found' | 'error';

type Props = {
/**
Expand Down Expand Up @@ -124,7 +121,7 @@ export default class App extends React.Component<Props, State> {
const deviceId = await getDeviceIdAsync();

// Initialize messaging transport
const testTransport = initialURL ? parseExperienceURL(initialURL)?.testTransport : null;
const testTransport = initialURL ? parseTestTransportFromUrl(initialURL) : null;
Messaging.init(deviceId, testTransport);

// Initialize various things
Expand Down Expand Up @@ -276,7 +273,7 @@ export default class App extends React.Component<Props, State> {
notifyStateChange(this.props, 'loading');

// Connect to the Snack website session, if the URL contains a channel or session ID
const channel = extractChannelFromSnackUrl(url);
const { channel, snack } = parseRuntimeUrl(url) ?? {};
if (channel) {
this._currentUrl = url;

Expand All @@ -297,26 +294,27 @@ export default class App extends React.Component<Props, State> {
}

// Load the Snack directly from the API when the URL does not contain a channel or session ID
const snackIdentifier = extractSnackIdentifierFromSnackUrl(url);
if (snackIdentifier) {
if (snack) {
this._currentUrl = url;

Logger.info('Opening URL', url);

this.setState({
channel: null,
snackIdentifier,
snackIdentifier: snack,
initialURL: url,
});

Messaging.unsubscribe();
Profiling.checkpoint('`_openUrl()` read');

// Load the code in the background, without blocking the UI
fetchCodeBySnackIdentifier(snackIdentifier).then((res) => {
if (res) this._handleCodeFetch(res);

// TODO: Handle proper error responses
fetchCodeBySnackIdentifier(snack).then((res) => {
if (res) {
this._handleCodeFetch(res);
} else {
notifyStateChange(this.props, 'error');
}
});

return true;
Expand Down Expand Up @@ -500,7 +498,15 @@ export default class App extends React.Component<Props, State> {
});
};

_handleCodeFetch = async (response: SnackApiCode) => {
_handleCodeFetch = async (response: SnackApiCode | SnackApiError) => {
if ('errors' in response) {
// Check if Snack was not found
if (response.errors.find((error) => error.code === 'SNACK_NOT_FOUND')) {
return notifyStateChange(this.props, 'not-found');
}
return notifyStateChange(this.props, 'error');
}

await Profiling.section(`Fetched code from API`, async () => {
this.setState(() => ({ isLoading: true }));

Expand Down
33 changes: 16 additions & 17 deletions packages/snack-runtime/src/Constants.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
import Constants from 'expo-constants';

/**
* The detected Snack environment based on the `manifest.extra.cloudEnv` setting.
* This defaults to `production` if not set.
*/
export const SNACK_ENVIRONMENT: 'staging' | 'production' =
Constants.manifest?.extra?.cloudEnv ?? 'production';
const snackEnv = process.env.EXPO_PUBLIC_SNACK_ENV ?? 'production';

// Ensure the environment is valid
if (!['staging', 'production'].includes(SNACK_ENVIRONMENT)) {
if (snackEnv === 'production' || snackEnv === 'staging') {
console.log('Snack is running in', snackEnv, 'mode');
} else {
throw new Error(
`Invalid Snack environment set through "manifest.extra.cloudEnv", must be "staging" or "production", received "${SNACK_ENVIRONMENT}".`,
`EXPO_PUBLIC_SNACK_ENV must be "staging" or "production", received "${snackEnv}".`,
);
}

/**
* The detected Snack environment based on the `EXPO_PUBLIC_SNACK_ENV` setting.
* This defaults to `production` if not set.
*/
export const SNACK_ENV = snackEnv;

/** Get the value based on the detected Snack environment. */
export function getSnackEnvironmentValue<T extends any>(
values: Record<typeof SNACK_ENVIRONMENT, T>,
): T {
return values[SNACK_ENVIRONMENT];
export function selectValueBySnackEnv<T extends any>(values: Record<typeof SNACK_ENV, T>): T {
return values[SNACK_ENV];
}

/** The Snack or Expo API endpoint. */
export const SNACK_API_URL = getSnackEnvironmentValue({
export const SNACK_API_URL = selectValueBySnackEnv({
production: 'https://exp.host',
staging: 'https://staging.exp.host',
});
Expand All @@ -32,7 +31,7 @@ export const SNACK_API_URL = getSnackEnvironmentValue({
* Note, staging may fail randomly due to reduced capacity or general development work.
* Because of that, we try both staging and production before failing.
*/
export const SNACKAGER_API_URLS = getSnackEnvironmentValue({
export const SNACKAGER_API_URLS = selectValueBySnackEnv({
production: ['https://d37p21p3n8r8ug.cloudfront.net'],
staging: [
'https://ductmb1crhe2d.cloudfront.net', // staging
Expand All @@ -41,7 +40,7 @@ export const SNACKAGER_API_URLS = getSnackEnvironmentValue({
});

/** The SnackPub endpoint, used to establish socket connections with the Snack Website. */
export const SNACKPUB_URL = getSnackEnvironmentValue({
export const SNACKPUB_URL = selectValueBySnackEnv({
production: 'https://snackpub.expo.dev',
staging: 'https://staging-snackpub.expo.dev',
});
2 changes: 1 addition & 1 deletion packages/snack-runtime/src/Modules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ const translatePipeline = async (load: Load) => {
Logger.module('Transpiling', sanitizeModule(filename), '...');

const result = babel.transform(load.source, {
presets: ['module:metro-react-native-babel-preset'],
presets: ['@react-native/babel-preset'],
plugins: [
['@babel/plugin-transform-async-to-generator'],
['@babel/plugin-proposal-decorators', { legacy: true }],
Expand Down
7 changes: 7 additions & 0 deletions packages/snack-runtime/src/UrlUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Return the `testTransport` query parameter from a Snack URL, if provided.
*/
export function parseTestTransportFromUrl(snackUrl: string) {
const url = new URL(snackUrl.replace(/^[a-z]+:/i, 'http://'));
return url.searchParams.get('testTransport');
}
2 changes: 2 additions & 0 deletions packages/snack-runtime/src/aliases/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const aliases: { [key: string]: any } = {
'react-native/Libraries/Core/Devtools/getDevServer': require('react-native/Libraries/Core/Devtools/getDevServer'), // Used by @sentry/[email protected]
'react-native/Libraries/Utilities/PolyfillFunctions': require('react-native/Libraries/Utilities/PolyfillFunctions'), // Used by @sentry/[email protected]
'react-native/Libraries/Utilities/codegenNativeCommands': require('react-native/Libraries/Utilities/codegenNativeCommands'), // Used by [email protected]
'react-native/Libraries/NativeComponent/NativeComponentRegistry': require('react-native/Libraries/NativeComponent/NativeComponentRegistry'), // Used by @shopify/[email protected]
'react-native/Libraries/Utilities/codegenNativeComponent': require('react-native/Libraries/Utilities/codegenNativeComponent'), // Used by react-native-svg
};

export default aliases;
3 changes: 3 additions & 0 deletions packages/snack-runtime/src/config/modules/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const modules: SnackConfig['modules'] = {

// Snack Runtime vendored modules
'react-native-view-shot': require('react-native-view-shot'),
'react-native-screens': require('react-native-screens'),

// React Native core modules
'react-native': require('react-native'),
Expand All @@ -26,4 +27,6 @@ export const modules: SnackConfig['modules'] = {
'react-native/Libraries/Core/Devtools/getDevServer': require('react-native/Libraries/Core/Devtools/getDevServer'), // Used by @sentry/[email protected]
'react-native/Libraries/Utilities/PolyfillFunctions': require('react-native/Libraries/Utilities/PolyfillFunctions'), // Used by @sentry/[email protected]
'react-native/Libraries/Utilities/codegenNativeCommands': require('react-native/Libraries/Utilities/codegenNativeCommands'), // Used by [email protected]
'react-native/Libraries/NativeComponent/NativeComponentRegistry': require('react-native/Libraries/NativeComponent/NativeComponentRegistry'), // Used by @shopify/[email protected]
'react-native/Libraries/Utilities/codegenNativeComponent': require('react-native/Libraries/Utilities/codegenNativeComponent'), // Used by react-native-svg
};
13 changes: 11 additions & 2 deletions packages/snack-runtime/src/utils/ExpoApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { SNACK_API_URL } from '../Constants';
import * as Logger from '../Logger';

export type SnackApiError = {
errors: {
code: string;
isTransient: false;
message: string;
}[];
};

export type SnackApiCode = {
id: string;
hashId: string;
Expand All @@ -23,12 +32,12 @@ export type SnackApiCode = {
*/
export async function fetchCodeBySnackIdentifier(
snackIdentifier: string,
): Promise<SnackApiCode | null> {
): Promise<SnackApiCode | SnackApiError | null> {
const snackId = snackIdentifier.startsWith('@snack/')
? snackIdentifier.substring('@snack/'.length)
: snackIdentifier;
try {
const res = await fetch(`https://exp.host/--/api/v2/snack/${snackId}`, {
const res = await fetch(`${SNACK_API_URL}/--/api/v2/snack/${snackId}`, {
method: 'GET',
headers: {
'Snack-Api-Version': '3.0.0',
Expand Down
Loading

0 comments on commit 35bf417

Please sign in to comment.