Skip to content

Commit

Permalink
feature(react): separate entry point for RSC
Browse files Browse the repository at this point in the history
  • Loading branch information
timofei-iatsenko committed Aug 15, 2023
1 parent 4af4448 commit 223f44a
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 151 deletions.
2 changes: 1 addition & 1 deletion packages/react/src/I18nProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { ComponentType, FunctionComponent } from "react"
import type { I18n } from "@lingui/core"
import type { TransRenderProps } from "./Trans"
import type { TransRenderProps } from "./TransNoContext"

export type I18nContext = {
i18n: I18n
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Trans.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { setupI18n } from "@lingui/core"
import { mockConsole } from "@lingui/jest-mocks"
import { PropsWithChildren } from "react"
import { TransNoContext } from "./Trans"
import { TransNoContext } from "./TransNoContext"

describe("Trans component", () => {
/*
Expand Down
149 changes: 2 additions & 147 deletions packages/react/src/Trans.tsx
Original file line number Diff line number Diff line change
@@ -1,154 +1,9 @@
import React, { ComponentType } from "react"
import React from "react"

import { useLingui } from "./I18nProvider"
import { formatElements } from "./format"
import type { MessageOptions } from "@lingui/core"
import { I18n } from "@lingui/core"

export type TransRenderProps = {
id: string
translation: React.ReactNode
children: React.ReactNode
message?: string | null
isTranslated: boolean
}

export type TransRenderCallbackOrComponent =
| {
component?: undefined
render?:
| ((props: TransRenderProps) => React.ReactElement<any, any>)
| null
}
| {
component?: React.ComponentType<TransRenderProps> | null
render?: undefined
}

export type TransProps = {
id: string
message?: string
values?: Record<string, unknown>
components?: { [key: string]: React.ElementType | any }
formats?: MessageOptions["formats"]
comment?: string
children?: React.ReactNode
} & TransRenderCallbackOrComponent
import { TransNoContext, TransProps } from "./TransNoContext"

export function Trans(props: TransProps): React.ReactElement<any, any> | null {
const lingui = useLingui()
return React.createElement(TransNoContext, { ...props, lingui })
}

/**
* Version of `<Trans>` component without using a Provider/Context React feature.
* Primarily made for support React Server Components (RSC)
*
* @experimental the api of this component is not stabilized yet.
*/
export function TransNoContext(
props: TransProps & {
lingui: { i18n: I18n; defaultComponent?: ComponentType<TransRenderProps> }
}
): React.ReactElement<any, any> | null {
const {
render,
component,
id,
message,
formats,
lingui: { i18n, defaultComponent },
} = props

const values = { ...props.values }
const components = { ...props.components }

if (values) {
/*
Related discussion: https://github.com/lingui/js-lingui/issues/183
Values *might* contain React elements with static content.
They're replaced with <INDEX /> placeholders and added to `components`.
Example:
Translation: Hello {name}
Values: { name: <strong>Jane</strong> }
It'll become "Hello <0 />" with components=[<strong>Jane</strong>]
*/

Object.keys(values).forEach((key) => {
const value = values[key]
const valueIsReactEl =
React.isValidElement(value) ||
(Array.isArray(value) && value.every(React.isValidElement))
if (!valueIsReactEl) return

const index = Object.keys(components).length

components[index] = value
values[key] = `<${index}/>`
})
}

const _translation: string =
i18n && typeof i18n._ === "function"
? i18n._(id, values, { message, formats })
: id // i18n provider isn't loaded at all

const translation = _translation
? formatElements(_translation, components)
: null

if (render === null || component === null) {
// Although `string` is a valid react element, types only allow `Element`
// Upstream issue: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/20544
return translation as unknown as React.ReactElement<any, any>
}

const FallbackComponent: React.ComponentType<TransRenderProps> =
defaultComponent || RenderFragment

const i18nProps: TransRenderProps = {
id,
message,
translation,
isTranslated: id !== translation && message !== translation,
children: translation, // for type-compatibility with `component` prop
}

// Validation of `render` and `component` props
if (render && component) {
console.error(
"You can't use both `component` and `render` prop at the same time. `component` is ignored."
)
} else if (render && typeof render !== "function") {
console.error(
`Invalid value supplied to prop \`render\`. It must be a function, provided ${render}`
)
} else if (component && typeof component !== "function") {
// Apparently, both function components and class components are functions
// See https://stackoverflow.com/a/41658173/1535540
console.error(
`Invalid value supplied to prop \`component\`. It must be a React component, provided ${component}`
)
return React.createElement(FallbackComponent, i18nProps, translation)
}

// Rendering using a render prop
if (typeof render === "function") {
// Component: render={(props) => <a title={props.translation}>x</a>}
return render(i18nProps)
}

// `component` prop has a higher precedence over `defaultComponent`
const Component: React.ComponentType<TransRenderProps> =
component || FallbackComponent

return React.createElement(Component, i18nProps, translation)
}

const RenderFragment = ({ children }: TransRenderProps) => {
// cannot use React.Fragment directly because we're passing in props that it doesn't support
return <React.Fragment>{children}</React.Fragment>
}
148 changes: 148 additions & 0 deletions packages/react/src/TransNoContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import React, { ComponentType } from "react"

import { formatElements } from "./format"
import type { MessageOptions } from "@lingui/core"
import { I18n } from "@lingui/core"

export type TransRenderProps = {
id: string
translation: React.ReactNode
children: React.ReactNode
message?: string | null
isTranslated: boolean
}

export type TransRenderCallbackOrComponent =
| {
component?: undefined
render?:
| ((props: TransRenderProps) => React.ReactElement<any, any>)
| null
}
| {
component?: React.ComponentType<TransRenderProps> | null
render?: undefined
}

export type TransProps = {
id: string
message?: string
values?: Record<string, unknown>
components?: { [key: string]: React.ElementType | any }
formats?: MessageOptions["formats"]
comment?: string
children?: React.ReactNode
} & TransRenderCallbackOrComponent

/**
* Version of `<Trans>` component without using a Provider/Context React feature.
* Primarily made for support React Server Components (RSC)
*
* @experimental the api of this component is not stabilized yet.
*/
export function TransNoContext(
props: TransProps & {
lingui: { i18n: I18n; defaultComponent?: ComponentType<TransRenderProps> }
}
): React.ReactElement<any, any> | null {
const {
render,
component,
id,
message,
formats,
lingui: { i18n, defaultComponent },
} = props

const values = { ...props.values }
const components = { ...props.components }

if (values) {
/*
Related discussion: https://github.com/lingui/js-lingui/issues/183
Values *might* contain React elements with static content.
They're replaced with <INDEX /> placeholders and added to `components`.
Example:
Translation: Hello {name}
Values: { name: <strong>Jane</strong> }
It'll become "Hello <0 />" with components=[<strong>Jane</strong>]
*/

Object.keys(values).forEach((key) => {
const value = values[key]
const valueIsReactEl =
React.isValidElement(value) ||
(Array.isArray(value) && value.every(React.isValidElement))
if (!valueIsReactEl) return

const index = Object.keys(components).length

components[index] = value
values[key] = `<${index}/>`
})
}

const _translation: string =
i18n && typeof i18n._ === "function"
? i18n._(id, values, { message, formats })
: id // i18n provider isn't loaded at all

const translation = _translation
? formatElements(_translation, components)
: null

if (render === null || component === null) {
// Although `string` is a valid react element, types only allow `Element`
// Upstream issue: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/20544
return translation as unknown as React.ReactElement<any, any>
}

const FallbackComponent: React.ComponentType<TransRenderProps> =
defaultComponent || RenderFragment

const i18nProps: TransRenderProps = {
id,
message,
translation,
isTranslated: id !== translation && message !== translation,
children: translation, // for type-compatibility with `component` prop
}

// Validation of `render` and `component` props
if (render && component) {
console.error(
"You can't use both `component` and `render` prop at the same time. `component` is ignored."
)
} else if (render && typeof render !== "function") {
console.error(
`Invalid value supplied to prop \`render\`. It must be a function, provided ${render}`
)
} else if (component && typeof component !== "function") {
// Apparently, both function components and class components are functions
// See https://stackoverflow.com/a/41658173/1535540
console.error(
`Invalid value supplied to prop \`component\`. It must be a React component, provided ${component}`
)
return React.createElement(FallbackComponent, i18nProps, translation)
}

// Rendering using a render prop
if (typeof render === "function") {
// Component: render={(props) => <a title={props.translation}>x</a>}
return render(i18nProps)
}

// `component` prop has a higher precedence over `defaultComponent`
const Component: React.ComponentType<TransRenderProps> =
component || FallbackComponent

return React.createElement(Component, i18nProps, translation)
}

const RenderFragment = ({ children }: TransRenderProps) => {
// cannot use React.Fragment directly because we're passing in props that it doesn't support
return <React.Fragment>{children}</React.Fragment>
}
4 changes: 2 additions & 2 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ export { I18nProvider, useLingui, LinguiContext } from "./I18nProvider"

export type { I18nProviderProps, I18nContext } from "./I18nProvider"

export { Trans, TransNoContext } from "./Trans"
export { Trans } from "./Trans"

export type {
TransProps,
TransRenderProps,
TransRenderCallbackOrComponent,
} from "./Trans"
} from "./TransNoContext"
13 changes: 13 additions & 0 deletions packages/react/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* This is an entry point for React Server Components (RSC)
*
* The RSC uses a static analysis to find any non-valid function calls in the import graph.
* That means this entry point and it's children should not have any Provider/Context calls.
*/
export { TransNoContext } from "./TransNoContext"

export type {
TransProps,
TransRenderProps,
TransRenderCallbackOrComponent,
} from "./TransNoContext"

0 comments on commit 223f44a

Please sign in to comment.