diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index af5ac6aca..a67d7c451 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -388,7 +388,21 @@ export type InternalActiveVisit = ActiveVisit & { export type VisitId = unknown export type Component = unknown -export type InertiaAppResponse = Promise<{ head: string[]; body: string } | void> +export interface InertiaAppProgressOptions { + delay?: number + color?: string + includeCSS?: boolean + showSpinner?: boolean +} + +export type InertiaAppSSRContent = { head: string[]; body: string } +export type InertiaAppResponse = Promise + +export type CreateInertiaAppOptions = { + id?: string + progress?: InertiaAppProgressOptions | false + resolve: PageResolver +} export type HeadManagerTitleCallback = (title: string) => string export type HeadManagerOnUpdateCallback = (elements: string[]) => void @@ -601,7 +615,7 @@ export interface InfiniteScrollRef { } export interface InfiniteScrollComponentBaseProps { - data?: string + data: string buffer?: number as?: string manual?: boolean diff --git a/packages/react/src/App.ts b/packages/react/src/App.ts index fcf14ab80..f228503ab 100755 --- a/packages/react/src/App.ts +++ b/packages/react/src/App.ts @@ -1,26 +1,35 @@ -import { createHeadManager, PageHandler, router } from '@inertiajs/core' -import { createElement, useEffect, useMemo, useState } from 'react' +import { createHeadManager, Page, PageHandler, PageProps, router } from '@inertiajs/core' +import { ComponentType, createElement, ReactNode, useEffect, useMemo, useState } from 'react' import HeadContext from './HeadContext' import PageContext from './PageContext' +import { AppComponent, AppOptions, RenderChildrenOptions } from './createInertiaApp' + +type ReactPageHandler = (options: { component: ReactNode; page: Page; preserveState: boolean }) => Promise + +type CurrentPage = { + component: ReactNode + page: Page + key: number | null +} let currentIsInitialPage = true let routerIsInitialized = false -let swapComponent: PageHandler = async () => { +let swapComponent: ReactPageHandler = async () => { // Dummy function so we can init the router outside of the useEffect hook. This is // needed so `router.reload()` works right away (on mount) in any of the user's // components. We swap in the real function in the useEffect hook below. currentIsInitialPage = false } -export default function App({ +function App({ children, initialPage, initialComponent, resolveComponent, titleCallback, onHeadUpdate, -}) { - const [current, setCurrent] = useState({ +}: AppOptions): ReturnType> { + const [current, setCurrent] = useState({ component: initialComponent || null, page: initialPage, key: null, @@ -38,7 +47,7 @@ export default function App({ router.init({ initialPage, resolveComponent, - swapComponent: async (args) => swapComponent(args), + swapComponent: ((...args: Parameters) => swapComponent(...args)) as PageHandler, }) routerIsInitialized = true @@ -67,13 +76,13 @@ export default function App({ return createElement( HeadContext.Provider, { value: headManager }, - createElement(PageContext.Provider, { value: current.page }, null), + createElement(PageContext.Provider, { value: current.page }), ) } const renderChildren = children || - (({ Component, props, key }) => { + (({ Component, props, key }: RenderChildrenOptions) => { const child = createElement(Component, { key, ...props }) if (typeof Component.layout === 'function') { @@ -81,10 +90,12 @@ export default function App({ } if (Array.isArray(Component.layout)) { - return Component.layout - .concat(child) + return [...Component.layout] .reverse() - .reduce((children, Layout) => createElement(Layout, { children, ...props })) + .reduce( + (children: ReactNode, Layout: ComponentType): ReactNode => createElement(Layout, { children, ...props }), + child, + ) } return child @@ -97,12 +108,14 @@ export default function App({ PageContext.Provider, { value: current.page }, renderChildren({ - Component: current.component, + Component: current.component as unknown as ComponentType, key: current.key, - props: current.page.props, - }), + props: current.page.props as Page['props'], + }) as ReactNode, ), ) } App.displayName = 'Inertia' + +export default App diff --git a/packages/react/src/Head.ts b/packages/react/src/Head.ts index ca679ae8e..a2f3e03a1 100644 --- a/packages/react/src/Head.ts +++ b/packages/react/src/Head.ts @@ -2,28 +2,29 @@ import { escape } from 'lodash-es' import React, { FunctionComponent, useContext, useEffect, useMemo } from 'react' import HeadContext from './HeadContext' +type HeadElement = React.ReactElement & { 'head-key'?: string; inertia?: string }> +type InertiaHead = FunctionComponent type InertiaHeadProps = { title?: string - children?: React.ReactNode + children?: HeadElement[] } -type InertiaHead = FunctionComponent - const Head: InertiaHead = function ({ children, title }) { - const headManager = useContext(HeadContext) + const headManager = useContext(HeadContext)! const provider = useMemo(() => headManager.createProvider(), [headManager]) const isServer = typeof window === 'undefined' useEffect(() => { provider.reconnect() - provider.update(renderNodes(children)) + provider.update(renderNodes(children || [])) return () => { provider.disconnect() } }, [provider, children, title]) - function isUnaryTag(node) { + function isUnaryTag(node: HeadElement): boolean { return ( + typeof node.type === 'string' && [ 'area', 'base', @@ -44,12 +45,15 @@ const Head: InertiaHead = function ({ children, title }) { ) } - function renderTagStart(node) { - const attrs = Object.keys(node.props).reduce((carry, name) => { + function renderTagStart(node: HeadElement): string { + const props = node.props as Record + const attrs = Object.keys(props).reduce((carry, name) => { if (['head-key', 'children', 'dangerouslySetInnerHTML'].includes(name)) { return carry } - const value = String(node.props[name]) + + const value = String(props[name]) + if (value === '') { return carry + ` ${name}` } else { @@ -59,48 +63,62 @@ const Head: InertiaHead = function ({ children, title }) { return `<${node.type}${attrs}>` } - function renderTagChildren(node) { - return typeof node.props.children === 'string' - ? node.props.children - : node.props.children.reduce((html, child) => html + renderTag(child), '') + function renderTagChildren(node: HeadElement): string { + const { children } = node.props + + if (typeof children === 'string') { + return children + } + + if (Array.isArray(children)) { + return children.reduce((html: string, child: HeadElement) => html + renderTag(child), '') + } + + return '' } - function renderTag(node) { + function renderTag(node: HeadElement): string { let html = renderTagStart(node) + if (node.props.children) { html += renderTagChildren(node) } + if (node.props.dangerouslySetInnerHTML) { html += node.props.dangerouslySetInnerHTML.__html } + if (!isUnaryTag(node)) { html += `` } + return html } - function ensureNodeHasInertiaProp(node) { + function ensureNodeHasInertiaProp(node: HeadElement): HeadElement { return React.cloneElement(node, { inertia: node.props['head-key'] !== undefined ? node.props['head-key'] : '', }) } - function renderNode(node) { + function renderNode(node: HeadElement): string { return renderTag(ensureNodeHasInertiaProp(node)) } - function renderNodes(nodes) { + function renderNodes(nodes: HeadElement[]): Array { const computed = React.Children.toArray(nodes) .filter((node) => node) - .map((node) => renderNode(node)) + .map((node) => renderNode(node as HeadElement)) + if (title && !computed.find((tag) => tag.startsWith('${title}`) } + return computed } if (isServer) { - provider.update(renderNodes(children)) + provider.update(renderNodes(children || [])) } return null diff --git a/packages/react/src/createInertiaApp.ts b/packages/react/src/createInertiaApp.ts index 7bfaedd6c..4739dfe21 100644 --- a/packages/react/src/createInertiaApp.ts +++ b/packages/react/src/createInertiaApp.ts @@ -1,9 +1,10 @@ import { + CreateInertiaAppOptions, HeadManagerOnUpdateCallback, HeadManagerTitleCallback, + InertiaAppResponse, Page, PageProps, - PageResolver, router, setupProgress, } from '@inertiajs/core' @@ -11,63 +12,58 @@ import { ComponentType, FunctionComponent, Key, ReactElement, ReactNode, createE import { renderToString } from 'react-dom/server' import App from './App' -type ReactInstance = ReactElement -type ReactComponent = ReactNode +type ReactPageResolver = (name: string) => Promise -type AppType = FunctionComponent< - { - children?: (props: { Component: ComponentType; key: Key; props: Page['props'] }) => ReactNode - } & SetupOptions['props'] -> +type SetupProps = { + initialPage: Page + initialComponent: ReactNode + resolveComponent: ReactPageResolver + titleCallback?: HeadManagerTitleCallback + onHeadUpdate?: HeadManagerOnUpdateCallback +} -export type SetupOptions = { - el: ElementType - App: AppType - props: { - initialPage: Page - initialComponent: ReactComponent - resolveComponent: PageResolver - titleCallback?: HeadManagerTitleCallback - onHeadUpdate?: HeadManagerOnUpdateCallback - } +export type InertiaComponentType = ComponentType & { + layout?: ((page: ReactNode) => ReactNode) | ComponentType[] } -type BaseInertiaAppOptions = { - title?: HeadManagerTitleCallback - resolve: PageResolver +export type RenderChildrenOptions = { + Component: InertiaComponentType + key: Key | null + props: Page['props'] } -type CreateInertiaAppSetupReturnType = ReactInstance | void -type InertiaAppOptionsForCSR = BaseInertiaAppOptions & { - id?: string - page?: Page | string +export type AppOptions = SetupProps & { + children?: (props: RenderChildrenOptions) => ReactNode +} + +export type AppComponent = FunctionComponent> + +type SetupOptions = { + el: ElementType + App: AppComponent + props: Omit, 'children'> +} + +type InertiaAppOptionsForCSR = CreateInertiaAppOptions & { + title?: HeadManagerTitleCallback + page?: Page render?: undefined - progress?: - | false - | { - delay?: number - color?: string - includeCSS?: boolean - showSpinner?: boolean - } - setup(options: SetupOptions): CreateInertiaAppSetupReturnType + setup(options: SetupOptions): void } -type CreateInertiaAppSSRContent = { head: string[]; body: string } -type InertiaAppOptionsForSSR = BaseInertiaAppOptions & { - id?: undefined - page: Page | string +type InertiaAppOptionsForSSR = CreateInertiaAppOptions & { + title?: HeadManagerTitleCallback + page: Page render: typeof renderToString - progress?: undefined - setup(options: SetupOptions): ReactInstance + setup(options: SetupOptions): ReactElement } export default async function createInertiaApp( options: InertiaAppOptionsForCSR, -): Promise +): InertiaAppResponse export default async function createInertiaApp( options: InertiaAppOptionsForSSR, -): Promise +): InertiaAppResponse export default async function createInertiaApp({ id = 'app', resolve, @@ -76,32 +72,45 @@ export default async function createInertiaApp | InertiaAppOptionsForSSR): Promise< - CreateInertiaAppSetupReturnType | CreateInertiaAppSSRContent -> { +}: InertiaAppOptionsForCSR | InertiaAppOptionsForSSR): InertiaAppResponse { const isServer = typeof window === 'undefined' const el = isServer ? null : document.getElementById(id) - const initialPage = page || JSON.parse(el.dataset.page) - // @ts-expect-error - const resolveComponent = (name) => Promise.resolve(resolve(name)).then((module) => module.default || module) + const initialPage = page || (JSON.parse(el?.dataset.page ?? '{}') as Page) + const resolveComponent: ReactPageResolver = (name) => + Promise.resolve(resolve(name)).then((module) => { + const m = module as { default?: ReactNode } + return m.default || (module as ReactNode) + }) - let head = [] + let head: string[] = [] const reactApp = await Promise.all([ resolveComponent(initialPage.component), router.decryptHistory().catch(() => {}), ]).then(([initialComponent]) => { - return setup({ - // @ts-expect-error - el, + const props = { + initialPage, + initialComponent, + resolveComponent, + titleCallback: title, + } + + if (isServer) { + const ssrSetup = setup as (options: SetupOptions) => ReactElement + + return ssrSetup({ + el: null, + App, + props: { ...props, onHeadUpdate: (elements: string[]) => (head = elements) }, + }) + } + + const csrSetup = setup as (options: SetupOptions) => void + + return csrSetup({ + el: el as HTMLElement, App, - props: { - initialPage, - initialComponent, - resolveComponent, - titleCallback: title, - onHeadUpdate: isServer ? (elements) => (head = elements) : null, - }, + props, }) }) @@ -109,7 +118,7 @@ export default async function createInertiaApp - import type { ComponentResolver, ResolvedComponent } from '../types' - import { type Page } from '@inertiajs/core' - - export interface InertiaAppProps { - initialComponent: ResolvedComponent - initialPage: Page - resolveComponent: ComponentResolver - } - -