Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

plugin: React Portability Patterns #3

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/expo/app/ExpoRootLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import { Stack } from 'expo-router'
import UniversalAppProviders from '@app/core/screens/UniversalAppProviders'
import UniversalRootLayout from '@app/core/screens/UniversalRootLayout'
import { Image as ExpoContextImage } from '@app/core/components/Image.expo'
import { Link as ExpoContextLink } from '@app/core/navigation/Link.expo'
import { useRouter as useExpoContextRouter } from '@app/core/navigation/useRouter.expo'
import { useRouteParams as useExpoRouteParams } from '@app/core/navigation/useRouteParams.expo'

// -i- Expo Router's layout setup is much simpler than Next.js's layout setup
// -i- Since Expo doesn't require a custom document setup or server component root layout
// -i- Use this file to apply your Expo specific layout setup:
// -i- like rendering our Universal Layout and App Providers

/* --- <ExpoRootLayout/> ----------------------------------------------------------------------- */

export default function ExpoRootLayout() {
// Navigation
const expoContextRouter = useExpoContextRouter()

// -- Render --

return (
<UniversalAppProviders>
<UniversalAppProviders
contextImage={ExpoContextImage}
contextLink={ExpoContextLink}
contextRouter={expoContextRouter}
useContextRouteParams={useExpoRouteParams}
>
<UniversalRootLayout>
<Stack
screenOptions={{
Expand Down
26 changes: 21 additions & 5 deletions apps/next/app/NextClientRootLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
'use client'
import React from 'react'
import UniversalAppProviders from '@app/screens/UniversalAppProviders'
import { Image as NextContextImage } from '@app/core/components/Image.next'
import { Link as NextContextLink } from '@app/core/navigation/Link.next'
import { useRouter as useNextContextRouter } from '@app/core/navigation/useRouter.next'
import { useRouteParams as useNextRouteParams } from '@app/core/navigation/useRouteParams.next'

// -i- This is a regular react client component
// -i- It's still rendered on the server during SSR, but it also hydrates on the client
Expand All @@ -15,11 +19,23 @@ type NextClientRootLayoutProps = {

/* --- <NextClientRootLayout/> ---------------------------------------------------------------- */

const NextClientRootLayout = ({ children }: NextClientRootLayoutProps) => (
<UniversalAppProviders>
{children}
</UniversalAppProviders>
)
const NextClientRootLayout = ({ children }: NextClientRootLayoutProps) => {
// Navigation
const nextContextRouter = useNextContextRouter()

// -- Render --

return (
<UniversalAppProviders
contextImage={NextContextImage}
contextLink={NextContextLink}
contextRouter={nextContextRouter}
useContextRouteParams={useNextRouteParams}
>
{children}
</UniversalAppProviders>
)
}

/* --- Exports --------------------------------------------------------------------------------- */

Expand Down
97 changes: 97 additions & 0 deletions features/app-core/components/Image.expo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Image as ExpoImage } from 'expo-image'
import { UniversalImageProps, UniversalImageMethods } from './Image.types'
import { Platform } from 'react-native'

/* --- <Image/> -------------------------------------------------------------------------------- */

const Image = (props: UniversalImageProps): JSX.Element => {
// Props
const {
/* - Universal - */
src,
alt,
width,
height,
style,
priority,
onError,
onLoadEnd,
/* - Split - */
expoPlaceholder,
/* - Next.js - */
onLoad,
fill,
/* - Expo - */
accessibilityLabel,
accessible,
allowDownscaling,
autoplay,
blurRadius,
cachePolicy,
contentFit,
contentPosition,
enableLiveTextInteraction,
focusable,
onLoadStart,
onProgress,
placeholderContentFit,
recyclingKey,
responsivePolicy,
} = props

// -- Overrides --

// @ts-ignore
const finalStyle = { width, height, ...style }
if (fill) finalStyle.height = '100%'
if (fill) finalStyle.width = '100%'

// -- Render --

return (
<ExpoImage
/* - Universal - */
source={src as any}
alt={alt || accessibilityLabel} // @ts-ignore
style={finalStyle}
priority={priority}
onError={onError}
onLoadEnd={onLoadEnd || onLoad as any}
/* - Split - */
placeholder={expoPlaceholder}
/* - Expo - */
accessibilityLabel={alt || accessibilityLabel}
accessible={accessible}
blurRadius={blurRadius}
cachePolicy={cachePolicy}
contentFit={contentFit}
contentPosition={contentPosition}
focusable={focusable}
onLoadStart={onLoadStart}
onProgress={onProgress}
placeholderContentFit={placeholderContentFit}
recyclingKey={recyclingKey}
responsivePolicy={responsivePolicy}
/* - Platform diffs - */
{...(Platform.select({
web: {},
native: {
autoplay,
enableLiveTextInteraction,
allowDownscaling,
},
}))}
/>
)
}

/* --- Static Methods -------------------------------------------------------------------------- */

Image.clearDiskCache = ExpoImage.clearDiskCache as UniversalImageMethods['clearDiskCache']
Image.clearMemoryCache = ExpoImage.clearMemoryCache as UniversalImageMethods['clearMemoryCache']
Image.getCachePathAsync = ExpoImage.getCachePathAsync as UniversalImageMethods['getCachePathAsync']
Image.prefetch = ExpoImage.prefetch as UniversalImageMethods['prefetch']

/* --- Exports --------------------------------------------------------------------------------- */

export { Image }
104 changes: 15 additions & 89 deletions features/app-core/components/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,22 @@
import { Image as ExpoImage } from 'expo-image'
import { Platform } from 'react-native'
import { UniversalImageProps, UniversalImageMethods } from './Image.types'
import React from 'react'
import type { UniversalImageProps, UniversalImageMethods } from './Image.types'
import { CoreContext } from '../context/CoreContext'

/* --- <Image/> -------------------------------------------------------------------------------- */
/* --- <Image/> --------------------------------------------------------------------------------- */

const Image = (props: UniversalImageProps): JSX.Element => {
// Props
const {
/* - Universal - */
src,
alt,
width,
height,
style,
priority,
onError,
onLoadEnd,
/* - Split - */
expoPlaceholder,
/* - Next.js - */
onLoad,
fill,
/* - Expo - */
accessibilityLabel,
accessible,
allowDownscaling,
autoplay,
blurRadius,
cachePolicy,
contentFit,
contentPosition,
enableLiveTextInteraction,
focusable,
onLoadStart,
onProgress,
placeholderContentFit,
recyclingKey,
responsivePolicy,
} = props
const Image = ((props: UniversalImageProps) => {
// Context
const { contextImage: ContextImage } = React.useContext(CoreContext)

// -- Overrides --
// Static methods
if (!Image.clearDiskCache) Image.clearDiskCache = ContextImage.clearDiskCache
if (!Image.clearMemoryCache) Image.clearMemoryCache = ContextImage.clearMemoryCache
if (!Image.getCachePathAsync) Image.getCachePathAsync = ContextImage.getCachePathAsync
if (!Image.prefetch) Image.prefetch = ContextImage.prefetch

// @ts-ignore
const finalStyle = { width, height, ...style }
if (fill) finalStyle.height = '100%'
if (fill) finalStyle.width = '100%'

// -- Render --

return (
<ExpoImage
/* - Universal - */
source={src as any}
alt={alt || accessibilityLabel} // @ts-ignore
style={finalStyle}
priority={priority}
onError={onError}
onLoadEnd={onLoadEnd || onLoad as any}
/* - Split - */
placeholder={expoPlaceholder}
/* - Expo - */
accessibilityLabel={alt || accessibilityLabel}
accessible={accessible}
blurRadius={blurRadius}
cachePolicy={cachePolicy}
contentFit={contentFit}
contentPosition={contentPosition}
focusable={focusable}
onLoadStart={onLoadStart}
onProgress={onProgress}
placeholderContentFit={placeholderContentFit}
recyclingKey={recyclingKey}
responsivePolicy={responsivePolicy}
/* - Platform diffs - */
{...(Platform.select({
web: {},
native: {
autoplay,
enableLiveTextInteraction,
allowDownscaling,
},
}))}
/>
)
}

/* --- Static Methods -------------------------------------------------------------------------- */

Image.clearDiskCache = ExpoImage.clearDiskCache as UniversalImageMethods['clearDiskCache']
Image.clearMemoryCache = ExpoImage.clearMemoryCache as UniversalImageMethods['clearMemoryCache']
Image.getCachePathAsync = ExpoImage.getCachePathAsync as UniversalImageMethods['getCachePathAsync']
Image.prefetch = ExpoImage.prefetch as UniversalImageMethods['prefetch']
// Render
return <ContextImage {...props} />
}) as ((props: UniversalImageProps) => JSX.Element) & UniversalImageMethods

/* --- Exports --------------------------------------------------------------------------------- */

Expand Down
35 changes: 35 additions & 0 deletions features/app-core/context/CoreContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react'
import { UniversalLinkProps } from '../navigation/Link.types'
import { UniversalRouterMethods } from '../navigation/useRouter.types'
import { UniversalRouteScreenProps } from '../navigation/useRouteParams.types'
import type { useLocalSearchParams } from 'expo-router'
import { UniversalImageMethods, UniversalImageProps } from '../components/Image.types'

// -i- This context's only aim is to provide React Portability & Framework Ejection patterns if required
// -i- By allowing you to provide your own custom Link and Router overrides, you could e.g.:
// -i- 1) Support Expo for Web by not defaulting to Next.js's Link and Router on web
// -i- 2) Eject from Next.js entirely and e.g. use another framework's Image / Link / router

/* --- Types ----------------------------------------------------------------------------------- */

export type CoreContextType = {
contextImage: ((props: UniversalImageProps) => JSX.Element) & UniversalImageMethods
contextLink: (props: UniversalLinkProps) => JSX.Element
contextRouter: UniversalRouterMethods
useContextRouteParams: (routeScreenProps: UniversalRouteScreenProps) => ReturnType<typeof useLocalSearchParams>
}

/* --- Dummy ----------------------------------------------------------------------------------- */

const createDummyComponent = (contextComponentName: string) => (props: any) => {
throw new Error(`CoreContext was not provided with a ${contextComponentName}. Please provide one in UniversalAppProviders.`)
}

/* --- Context --------------------------------------------------------------------------------- */

export const CoreContext = React.createContext<CoreContextType>({
contextImage: createDummyComponent('contextImage') as any,
contextLink: createDummyComponent('contextLink'),
contextRouter: null,
useContextRouteParams: () => ({}),
})
45 changes: 45 additions & 0 deletions features/app-core/navigation/Link.expo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Link as ExpoLink } from 'expo-router'
import type { UniversalLinkProps } from './Link.types'

/* --- <Link/> --------------------------------------------------------------------------------- */

export const Link = (props: UniversalLinkProps) => {
// Props
const {
children,
href,
style,
replace,
onPress,
target,
asChild,
push,
testID,
nativeID,
allowFontScaling,
numberOfLines,
maxFontSizeMultiplier
} = props

// -- Render --

return (
<ExpoLink
href={href}
style={style}
onPress={onPress}
target={target}
asChild={asChild}
replace={replace}
push={push}
testID={testID}
nativeID={nativeID}
allowFontScaling={allowFontScaling}
numberOfLines={numberOfLines}
maxFontSizeMultiplier={maxFontSizeMultiplier}
>
{children}
</ExpoLink>
)
}

Loading