diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e211e7b1..aab8fb18 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -82,6 +82,7 @@ body: - http - http-guzzle - inertia + - inertia-react - layout - log - log-file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 35f62029..9daf1825 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -70,6 +70,7 @@ body: - http - http-guzzle - inertia + - inertia-react - layout - log - log-file diff --git a/composer.json b/composer.json index 78950d85..0216e968 100644 --- a/composer.json +++ b/composer.json @@ -144,6 +144,10 @@ "type": "path", "url": "packages/inertia" }, + { + "type": "path", + "url": "packages/inertia-react" + }, { "type": "path", "url": "packages/layout" @@ -341,6 +345,7 @@ "marko/hashing": "self.version", "marko/health": "self.version", "marko/inertia": "self.version", + "marko/inertia-react": "self.version", "marko/http": "self.version", "marko/layout": "self.version", "marko/http-guzzle": "self.version", @@ -447,6 +452,7 @@ "Marko\\Hashing\\Tests\\": "packages/hashing/tests/", "Marko\\Health\\Tests\\": "packages/health/tests/", "Marko\\Inertia\\Tests\\": "packages/inertia/tests/", + "Marko\\Inertia\\React\\Tests\\": "packages/inertia-react/tests/", "Marko\\Http\\Tests\\": "packages/http/tests/", "Marko\\Http\\Guzzle\\Tests\\": "packages/http-guzzle/tests/", "Marko\\Layout\\Tests\\": "packages/layout/tests/", diff --git a/docs/src/content/docs/packages/inertia-react.md b/docs/src/content/docs/packages/inertia-react.md new file mode 100644 index 00000000..a793b568 --- /dev/null +++ b/docs/src/content/docs/packages/inertia-react.md @@ -0,0 +1,106 @@ +--- +title: marko/inertia-react +description: React companion for marko/inertia - configuration defaults and a frontend marker binding for React. +--- + +React companion for [`marko/inertia`](/docs/packages/inertia/) and [`marko/vite`](/docs/packages/vite/). It overlays the parent Inertia configuration with React defaults and registers a frontend marker binding so installing multiple Inertia frontend companions fails loudly. + +## Installation + +```bash +composer require marko/inertia-react +``` + +Install the matching frontend dependencies in your app: + +```bash +npm install @inertiajs/react react react-dom @vitejs/plugin-react vite +``` + +Refer to the [Inertia.js docs](https://inertiajs.com/) for currently supported versions of each frontend adapter. + +## Configuration + +This package contributes defaults to the parent `config/inertia.php` namespace: + +```php title="packages/inertia-react/config/inertia.php" +return [ + 'assetEntry' => env('INERTIA_REACT_CLIENT_ENTRY', 'app/react-web/resources/js/app.jsx'), +]; +``` + +| Key | Purpose | +| --- | --- | +| `assetEntry` | Vite entry used by browser-rendered Inertia responses. | + +## Usage + +Render React-backed Inertia pages without passing an asset entry; `marko/inertia` reads it from configuration: + +```php +use Marko\Inertia\Inertia; +use Marko\Routing\Http\Request; +use Marko\Routing\Http\Response; + +class DashboardController +{ + public function __construct( + private readonly Inertia $inertia, + ) {} + + public function index(Request $request): Response + { + return $this->inertia->render( + request: $request, + component: 'Dashboard', + ); + } +} +``` + +Create the client entry at `app/react-web/resources/js/app.jsx`: + +```jsx title="app/react-web/resources/js/app.jsx" +import { createInertiaApp } from '@inertiajs/react'; +import { createRoot } from 'react-dom/client'; + +createInertiaApp({ + resolve: (name) => { + const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true }); + return pages[`./Pages/${name}.jsx`]; + }, + setup({ el, App, props }) { + createRoot(el).render(); + }, +}); +``` + +Create the SSR entry at `app/react-web/resources/js/ssr.jsx`: + +```jsx title="app/react-web/resources/js/ssr.jsx" +import { createInertiaApp } from '@inertiajs/react'; +import createServer from '@inertiajs/react/server'; +import ReactDOMServer from 'react-dom/server'; + +createServer((page) => + createInertiaApp({ + page, + render: ReactDOMServer.renderToString, + resolve: (name) => { + const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true }); + return pages[`./Pages/${name}.jsx`]; + }, + setup: ({ App, props }) => , + }), +); +``` + +## API Reference + +This package registers `Marko\Inertia\Frontend\InertiaFrontendInterface` to a React marker implementation. Installing more than one Inertia frontend companion produces the same binding conflict protection used by Marko driver siblings. + +## Related Packages + +- [`marko/inertia`](/docs/packages/inertia/) - renders Inertia responses and handles SSR fallback +- [`marko/vite`](/docs/packages/vite/) - resolves the configured React Vite entry +- [`marko/env`](/docs/packages/env/) - provides the `env()` helper used in `config/inertia.php` diff --git a/docs/src/content/docs/packages/inertia.md b/docs/src/content/docs/packages/inertia.md index 097e9a25..f28cff54 100644 --- a/docs/src/content/docs/packages/inertia.md +++ b/docs/src/content/docs/packages/inertia.md @@ -20,6 +20,7 @@ Configure via `config/inertia.php`: ```php title="config/inertia.php" return [ 'version' => null, + 'assetEntry' => null, 'ssr' => [ 'enabled' => env('INERTIA_SSR_ENABLED', false), 'url' => env('INERTIA_SSR_URL', 'http://localhost:13714'), @@ -30,6 +31,7 @@ return [ | Key | Purpose | | --- | --- | | `version` | Asset version included in every Inertia page object. Set to `null` to disable version mismatch handling. | +| `assetEntry` | Default Vite entry used by `render()` when no `$assetEntry` argument is passed. Frontend companion packages (`marko/inertia-react`, `marko/inertia-vue`, `marko/inertia-svelte`) overlay this slot via `config/inertia.php` so installing one swaps the entry without code changes. | | `ssr.enabled` | When true, the initial browser response attempts to render through the configured Inertia SSR server. | | `ssr.url` | URL used by the SSR client when SSR is enabled. | @@ -77,7 +79,13 @@ For a normal browser visit, `render()` returns the initial HTML shell with the p ### Asset Entries -By default, the HTML shell uses the configured `vite.entry`. Pass an asset entry to target a frontend-specific bundle: +The Vite entry resolves in this order: + +1. The `$assetEntry` argument passed to `render()` (highest priority). +2. The `inertia.assetEntry` config slot, typically populated by a frontend companion package (`marko/inertia-react`, `marko/inertia-vue`, `marko/inertia-svelte`). +3. The `vite.entry` fallback from `marko/vite`. + +Pass an asset entry per call to override the configured default: ```php return $this->inertia->render( diff --git a/packages/inertia-react/.gitattributes b/packages/inertia-react/.gitattributes new file mode 100644 index 00000000..aa78278f --- /dev/null +++ b/packages/inertia-react/.gitattributes @@ -0,0 +1,5 @@ +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore diff --git a/packages/inertia-react/LICENSE b/packages/inertia-react/LICENSE new file mode 100644 index 00000000..99a76f97 --- /dev/null +++ b/packages/inertia-react/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) Devtomic LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/inertia-react/README.md b/packages/inertia-react/README.md new file mode 100644 index 00000000..6d4845f2 --- /dev/null +++ b/packages/inertia-react/README.md @@ -0,0 +1,36 @@ +# marko/inertia-react + +React companion for `marko/inertia` - configuration defaults and a frontend marker binding for React. + +## Installation + +```bash +composer require marko/inertia-react +``` + +## Quick Example + +```php +use Marko\Inertia\Inertia; +use Marko\Routing\Http\Request; +use Marko\Routing\Http\Response; + +class DashboardController +{ + public function __construct( + private readonly Inertia $inertia, + ) {} + + public function index(Request $request): Response + { + return $this->inertia->render( + request: $request, + component: 'Dashboard', + ); + } +} +``` + +## Documentation + +Full usage, API reference, and examples: [marko/inertia-react](https://marko.build/docs/packages/inertia-react/) diff --git a/packages/inertia-react/composer.json b/packages/inertia-react/composer.json new file mode 100644 index 00000000..ccff5633 --- /dev/null +++ b/packages/inertia-react/composer.json @@ -0,0 +1,28 @@ +{ + "name": "marko/inertia-react", + "description": "React companion for marko/inertia - configuration and frontend marker binding", + "license": "MIT", + "type": "marko-module", + "require": { + "php": "^8.5", + "marko/core": "self.version", + "marko/env": "self.version", + "marko/inertia": "self.version", + "marko/vite": "self.version" + }, + "autoload": { + "psr-4": { + "Marko\\Inertia\\React\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Marko\\Inertia\\React\\Tests\\": "tests/" + } + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/inertia-react/config/inertia.php b/packages/inertia-react/config/inertia.php new file mode 100644 index 00000000..022c6047 --- /dev/null +++ b/packages/inertia-react/config/inertia.php @@ -0,0 +1,7 @@ + env('INERTIA_REACT_CLIENT_ENTRY', 'app/react-web/resources/js/app.jsx'), +]; diff --git a/packages/inertia-react/module.php b/packages/inertia-react/module.php new file mode 100644 index 00000000..797abbd1 --- /dev/null +++ b/packages/inertia-react/module.php @@ -0,0 +1,12 @@ + [ + InertiaFrontendInterface::class => ReactInertiaFrontend::class, + ], +]; diff --git a/packages/inertia-react/src/ReactInertiaFrontend.php b/packages/inertia-react/src/ReactInertiaFrontend.php new file mode 100644 index 00000000..76a0b984 --- /dev/null +++ b/packages/inertia-react/src/ReactInertiaFrontend.php @@ -0,0 +1,15 @@ +toBe('app/react-web/resources/js/app.jsx'); + expect($config)->not->toHaveKey('ssrEntry'); +}); + +test('inertia-react binds the inertia frontend marker', function () { + $module = require dirname(__DIR__).'/module.php'; + + expect($module['bindings'])->toHaveKey(InertiaFrontendInterface::class) + ->and($module['bindings'][InertiaFrontendInterface::class])->toBe(ReactInertiaFrontend::class); +}); + +test('react inertia frontend identifies itself', function () { + expect((new ReactInertiaFrontend())->name())->toBe('react'); +}); + +test('inertia-react is a marko module', function () { + $composer = json_decode( + file_get_contents(dirname(__DIR__).'/composer.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + + expect(file_exists(dirname(__DIR__).'/module.php'))->toBeTrue() + ->and($composer['extra']['marko']['module'])->toBeTrue() + ->and($composer['autoload']['psr-4'])->toHaveKey('Marko\\Inertia\\React\\'); +}); diff --git a/packages/inertia/config/inertia.php b/packages/inertia/config/inertia.php index a79781b5..11e1145d 100644 --- a/packages/inertia/config/inertia.php +++ b/packages/inertia/config/inertia.php @@ -4,6 +4,7 @@ return [ 'version' => null, + 'assetEntry' => null, 'ssr' => [ 'enabled' => env('INERTIA_SSR_ENABLED', false), 'url' => env('INERTIA_SSR_URL', 'http://localhost:13714'), diff --git a/packages/inertia/src/Frontend/InertiaFrontendInterface.php b/packages/inertia/src/Frontend/InertiaFrontendInterface.php new file mode 100644 index 00000000..e76246c0 --- /dev/null +++ b/packages/inertia/src/Frontend/InertiaFrontendInterface.php @@ -0,0 +1,10 @@ +nullableScalarConfig('inertia.assetEntry'); $props = $this->resolveProps($props, $request, $component); $page = [ diff --git a/packages/inertia/tests/InertiaTest.php b/packages/inertia/tests/InertiaTest.php index f96dcf8d..9eece8ed 100644 --- a/packages/inertia/tests/InertiaTest.php +++ b/packages/inertia/tests/InertiaTest.php @@ -21,6 +21,7 @@ function createInertia(array $config = [], array $viteConfig = []): Inertia { $mergedConfig = new FakeConfigRepository(array_merge([ 'inertia.version' => '1.0', + 'inertia.assetEntry' => null, 'inertia.ssr.enabled' => false, 'inertia.ssr.url' => 'http://localhost:13714', 'vite.entry' => 'app/web/resources/js/app.js', @@ -104,6 +105,23 @@ function createInertia(array $config = [], array $viteConfig = []): Inertia expect($response->body())->not->toContain('app/web/resources/js/app.js'); }); +test('inertia html defaults to the configured inertia asset entry when present', function () { + $inertia = createInertia([ + 'inertia.assetEntry' => 'app/react-web/resources/js/app.jsx', + ], [ + 'entry' => 'app/web/resources/js/app.js', + 'devServerUrl' => 'http://localhost:5173', + 'devServerStylesheets' => [], + 'useDevServer' => true, + ]); + $request = new Request(); + + $response = $inertia->render($request, 'ReactHome'); + + expect($response->body())->toContain('http://localhost:5173/app/react-web/resources/js/app.jsx'); + expect($response->body())->not->toContain('app/web/resources/js/app.js'); +}); + test('inertia merges shared data with page props', function () { $inertia = createInertia(); $inertia->share('flash', ['message' => 'Hello']); diff --git a/packages/inertia/tests/ModuleTest.php b/packages/inertia/tests/ModuleTest.php index f05f71e8..d202d40f 100644 --- a/packages/inertia/tests/ModuleTest.php +++ b/packages/inertia/tests/ModuleTest.php @@ -24,3 +24,12 @@ expect($module)->not->toHaveKey('enabled') ->and($module)->not->toHaveKey('sequence'); }); + +test('inertia config declares frontend overlay slots', function () { + $config = require dirname(__DIR__).'/config/inertia.php'; + + expect($config['version'])->toBeNull() + ->and($config['assetEntry'])->toBeNull() + ->and($config['ssr']['enabled'])->toBeFalse() + ->and($config['ssr']['url'])->toBe('http://localhost:13714'); +});