Skip to content

Commit

Permalink
feature(runtime): vendor Expo Router to enable partial usage (#436)
Browse files Browse the repository at this point in the history
* fix(runtime): add scheme for `expo-router` internal linking

* chore(runtime): vendor `expo-router` and dependencies

* chore(runtime): vendor `expo-router` within the runtime

* chore(runtime): linting issue

* refactor(require-context): expose `createVirtualModulePath` for the Snack Runtime

* refactor(runtime): replace hardcoded virtual module with `createVirtualModulePath`
  • Loading branch information
byCedric committed Jun 22, 2023
1 parent 2425443 commit 1884dd9
Show file tree
Hide file tree
Showing 11 changed files with 292 additions and 22 deletions.
1 change: 1 addition & 0 deletions packages/snack-require-context/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
resolveContextDirectory,
pathIsVirtualModule,
convertVirtualModulePathToRequest,
createVirtualModulePath,
} from './utils/context';

export { sanitizeFilePath } from './utils/path';
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
SnackRequireContextRequest,
convertRequestToVirtualModulePath,
createVirtualModulePath,
convertVirtualModulePathToRequest,
createContextModuleTemplate,
createEmptyContextModuleTemplate,
Expand All @@ -27,22 +27,22 @@ describe(pathIsVirtualModule, () => {
});
});

describe(convertRequestToVirtualModulePath, () => {
describe(createVirtualModulePath, () => {
it('converts request to URL-safe virtual module path', () => {
const request = requireContext('app', false, /\.tsx$/, 'sync');
const virtualModule = convertRequestToVirtualModulePath(request);
const virtualModule = createVirtualModulePath(request);
expect(virtualModule).toBe(encodeURI(virtualModule));
});

it('converts empty paths', () => {
const request = requireContext('', true, /\.mdx$/, 'async');
const virtualModule = convertRequestToVirtualModulePath(request);
const virtualModule = createVirtualModulePath(request);
expect(virtualModule).toBe(encodeURI(virtualModule));
});

it('converts back and forth with identical request', () => {
const request = requireContext('components', true, /.*/, 'async');
const virtualModule = convertRequestToVirtualModulePath(request);
const virtualModule = createVirtualModulePath(request);
const convertedRequest = convertVirtualModulePathToRequest(virtualModule);
expect(convertedRequest).toEqual(request);
expect(convertedRequest).toHaveProperty('directory', 'components');
Expand All @@ -61,7 +61,7 @@ describe(convertVirtualModulePathToRequest, () => {

it('converts empty directories', () => {
const request = requireContext('', true, /\.tsx$/, 'sync');
const virtualModule = convertRequestToVirtualModulePath(request);
const virtualModule = createVirtualModulePath(request);
const convertedRequest = convertVirtualModulePathToRequest(virtualModule);
expect(convertedRequest).toEqual(request);
expect(convertedRequest).toHaveProperty('directory', '');
Expand Down
4 changes: 2 additions & 2 deletions packages/snack-require-context/src/utils/babel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type * as BabelCore from '@babel/core';
import type { CallExpression } from '@babel/types';

import { convertRequestToVirtualModulePath, resolveContextDirectory } from './context';
import { createVirtualModulePath, resolveContextDirectory } from './context';
import { sanitizeFilePath } from './path';

const defaultEnvVars: Record<string, string> = {
Expand Down Expand Up @@ -53,7 +53,7 @@ export function snackRequireContextVirtualModuleBabelPlugin({
: directory;

// Convert the arguments into a virtual module path
const contextModule = convertRequestToVirtualModulePath({
const contextModule = createVirtualModulePath({
directory: contextDirectory,
isRecursive,
matching: matching ? new RegExp(matching) : undefined,
Expand Down
2 changes: 1 addition & 1 deletion packages/snack-require-context/src/utils/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function pathIsVirtualModule(modulePath: string) {
* Create the path of a virtual module that represents a `require.context` result.
* This embeds the context options into a base64 encoded query string, to evaluate inside the Snack Runtime.
*/
export function convertRequestToVirtualModulePath(
export function createVirtualModulePath(
request: Omit<Partial<SnackRequireContextRequest>, 'directory'> &
Pick<SnackRequireContextRequest, 'directory'>
) {
Expand Down
1 change: 1 addition & 0 deletions runtime/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"expo": {
"name": "Snack",
"description": "Write code in Expo's online editor and instantly use it on your phone",
"scheme": "snack",
"owner": "exponent",
"slug": "snack",
"version": "1.0.0",
Expand Down
5 changes: 5 additions & 0 deletions runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"@babel/polyfill": "^7.8.3",
"@expo/vector-icons": "^13.0.0",
"@react-native-async-storage/async-storage": "1.17.11",
"@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6",
"await-lock": "^2.2.2",
"canvaskit-wasm": "0.38.0",
"diff": "^5.0.0",
Expand All @@ -34,7 +36,9 @@
"expo-file-system": "~15.2.2",
"expo-font": "~11.1.1",
"expo-keep-awake": "~12.0.1",
"expo-linking": "~4.0.1",
"expo-random": "~13.1.1",
"expo-router": "^1.5.3",
"expo-splash-screen": "~0.18.2",
"expo-status-bar": "~1.4.4",
"expo-updates": "~0.16.4",
Expand All @@ -47,6 +51,7 @@
"react-native-gesture-handler": "~2.9.0",
"react-native-reanimated": "~2.14.4",
"react-native-safe-area-context": "4.5.0",
"react-native-screens": "~3.20.0",
"react-native-view-shot": "3.5.0",
"react-native-web": "~0.18.10",
"snack-babel-standalone": "^2.2.0",
Expand Down
24 changes: 16 additions & 8 deletions runtime/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
EmitterSubscription,
NativeEventSubscription,
} from 'react-native';
import { createVirtualModulePath } from 'snack-require-context';

import * as Analytics from './Analytics';
import { AppLoading } from './AppLoading';
Expand All @@ -26,6 +27,7 @@ import * as Logger from './Logger';
import * as Messaging from './Messaging';
import * as Modules from './Modules';
import EXDevLauncher from './NativeModules/EXDevLauncher';
import { ExpoRouterApp } from './NativeModules/ExpoRouterEntry';
import Linking from './NativeModules/Linking';
import { captureRef as takeSnapshotAsync } from './NativeModules/ViewShot';
import getDeviceIdAsync from './NativeModules/getDeviceIdAsync';
Expand Down Expand Up @@ -437,19 +439,25 @@ export default class App extends React.Component<object, State> {
let rootElement: React.ReactElement | undefined;
try {
const rootModuleUri = 'module://' + Files.entry();

if (changedPaths.length > 0) {
await Modules.flush({ changedPaths, changedUris: [rootModuleUri] });
}

const hasRootModuleUri = await Modules.has(rootModuleUri);
if (!hasRootModuleUri) {
const rootDefaultExport = (await Modules.load(rootModuleUri)).default;
if (!rootDefaultExport) {
throw new Error(`No default export of '${Files.entry()}' to render!`);
// Special handling for Expo Router projects
if (Modules.hasDependency('expo-router')) {
const ctx = await Modules.load(createVirtualModulePath({ directory: 'module://app' }));
Logger.info('Updating Expo Router root element');
rootElement = React.createElement(ExpoRouterApp, { ctx });
} else {
const hasRootModuleUri = await Modules.has(rootModuleUri);
if (!hasRootModuleUri) {
const rootDefaultExport = (await Modules.load(rootModuleUri)).default;
if (!rootDefaultExport) {
throw new Error(`No default export of '${Files.entry()}' to render!`);
}
Logger.info('Updating root element');
rootElement = React.createElement(rootDefaultExport);
}
Logger.info('Updating root element');
rootElement = React.createElement(rootDefaultExport);
}
} catch (e) {
Errors.report(e);
Expand Down
4 changes: 4 additions & 0 deletions runtime/src/Modules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export const updateProjectDependencies = async (newProjectDependencies: Dependen
return changedDependencies.map(sanitizeModule);
};

export function hasDependency(name: string) {
return !!projectDependencies[name];
}

// SystemJS fetch pipeline
const _get = (header: { [key: string]: string }, value: string) =>
header?.hasOwnProperty(value) ? header[value] : null;
Expand Down
18 changes: 18 additions & 0 deletions runtime/src/NativeModules/ExpoRouterEntry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ExpoRoot } from 'expo-router';
import Head from 'expo-router/head';

type ExpoRouterAppProps = {
ctx: any;
};

/**
* Used as alternative `expo-router/entry`, that works with Snack.
* Instead of registering the root component through API, this returns a component to render.
*/
export function ExpoRouterApp({ ctx }: ExpoRouterAppProps) {
return (
<Head.Provider>
<ExpoRoot context={ctx} />
</Head.Provider>
);
}
9 changes: 9 additions & 0 deletions runtime/src/aliases/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ const aliases: { [key: string]: any } = {

// Used by @shopify/react-native-skia, on web only
'@shopify/react-native-skia/lib/module/web': SkiaWeb,

// Only works when vendored into the runtime ([email protected])
'expo-router': require('expo-router'),
'expo-router/stack': require('expo-router/stack'),
'expo-router/tabs': require('expo-router/tabs'),
'expo-router/drawer': require('expo-router/drawer'),
'expo-router/html': require('expo-router/html'),
'expo-router/head': require('expo-router/head'),
'expo-router/entry': () => {}, // noop
};

export default aliases;
Loading

0 comments on commit 1884dd9

Please sign in to comment.