diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 9a3adb84..addbad91 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -56,6 +56,7 @@ jobs:
SESSION_DRIVER: file
QUEUE_CONNECTION: sync
MAIL_MAILER: log
+ PHP_CLI_SERVER_WORKERS: '4'
MODULO_E2E_URL: http://127.0.0.1:8000
# Asset and link URLs must match where the browser reaches the app.
APP_URL: http://127.0.0.1:8000
@@ -112,7 +113,7 @@ jobs:
echo "MODULO_E2E_SETUP_TOKEN=$setup_token" >> "$GITHUB_ENV"
# Deliberately not migrated or seeded: the wizard is what should
# bring an empty database up, and that is the thing under test.
- npm run build
+ npm run build:ssr
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
@@ -134,8 +135,34 @@ jobs:
exit 1
- name: Run the end-to-end tests
+ env:
+ MODULO_E2E_FIXTURES: 'true'
run: npm run test:e2e --
+ - name: Verify server rendering and hydration
+ env:
+ INERTIA_SSR_ENABLED: 'true'
+ MODULO_E2E_SSR: '1'
+ MODULO_E2E_URL: http://127.0.0.1:8002
+ APP_URL: http://127.0.0.1:8002
+ ASSET_URL: http://127.0.0.1:8002
+ run: |
+ set -euo pipefail
+ node bootstrap/ssr/ssr.js >/tmp/modulo-e2e-ssr.log 2>&1 &
+ ssr_pid=$!
+ trap 'kill "$ssr_pid" 2>/dev/null || true' EXIT
+ for _ in $(seq 1 15); do
+ if curl -fsS http://127.0.0.1:13714/health >/dev/null 2>&1; then break; fi
+ sleep 1
+ done
+ node scripts/check-ssr.mjs
+ php artisan serve --no-reload --host=127.0.0.1 --port=8002 &
+ for _ in $(seq 1 15); do
+ if curl -fsS http://127.0.0.1:8002/health >/dev/null 2>&1; then break; fi
+ sleep 1
+ done
+ npm run test:e2e -- ssr.spec.ts
+
- name: Seed and audit the compact disposable demo
env:
MODULO_DEMO: 'true'
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index e5afc17d..c846b494 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -372,7 +372,9 @@ jobs:
run: npm run test:js
- name: Build client and server assets
- run: npm run build:ssr
+ run: |
+ npm run build:ssr
+ npm run build:plugin-fixture
- name: Server rendering bundle starts
run: |
@@ -382,6 +384,7 @@ jobs:
trap 'kill "$ssr_pid" 2>/dev/null || true' EXIT
for _ in $(seq 1 10); do
if curl -fsS http://127.0.0.1:13714/health | grep -q '"status":"OK"'; then
+ node scripts/check-ssr.mjs
exit 0
fi
if ! kill -0 "$ssr_pid" 2>/dev/null; then break; fi
@@ -413,3 +416,4 @@ jobs:
- run: npm run types
- run: npm run test:js
- run: npm run build:ssr
+ - run: npm run build:plugin-fixture
diff --git a/.gitignore b/.gitignore
index 6f2c4153..aab2fa1a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,7 @@ build/
cookies.txt
/storage/media-library/
/storage/framework/phpstan
+/storage/validation/
/playwright-report
/test-results
/blob-report
diff --git a/app/Services/PageMeta.php b/app/Services/PageMeta.php
index 297c22a4..30c81641 100644
--- a/app/Services/PageMeta.php
+++ b/app/Services/PageMeta.php
@@ -10,7 +10,7 @@
*
* The React theme sets the same tags in the browser, but crawlers that don't
* run JavaScript and link previews (chat apps, social networks) only read the
- * HTML the server sends. These are printed with Inertia's `inertia` attribute,
+ * HTML the server sends. These are printed with Inertia's `data-inertia` attribute,
* so the theme replaces them on load instead of doubling them. The rules
* mirror the theme's Layout: keep the two in step.
*/
diff --git a/composer.json b/composer.json
index e2e90fe3..5027b813 100644
--- a/composer.json
+++ b/composer.json
@@ -24,7 +24,7 @@
},
"require": {
"php": "^8.4",
- "inertiajs/inertia-laravel": "^2.0",
+ "inertiajs/inertia-laravel": "^3.0",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "^3.0",
diff --git a/composer.lock b/composer.lock
index 5a72020a..f18ce0eb 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "afe5ed75611c03c6cf0330788b67ee7b",
+ "content-hash": "db481e008cd1a7b5aa7ee133e8a8c056",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -1288,34 +1288,34 @@
},
{
"name": "inertiajs/inertia-laravel",
- "version": "v2.0.28",
+ "version": "v3.5.1",
"source": {
"type": "git",
"url": "https://github.com/inertiajs/inertia-laravel.git",
- "reference": "ca0d670779dc61437026e9ec7fd3b50c99625ac2"
+ "reference": "35a27ea62b8a53ec40269c4c675db5d9d0e02426"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/ca0d670779dc61437026e9ec7fd3b50c99625ac2",
- "reference": "ca0d670779dc61437026e9ec7fd3b50c99625ac2",
+ "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/35a27ea62b8a53ec40269c4c675db5d9d0e02426",
+ "reference": "35a27ea62b8a53ec40269c4c675db5d9d0e02426",
"shasum": ""
},
"require": {
"ext-json": "*",
- "laravel/framework": "^10.0|^11.0|^12.0|^13.0",
- "php": "^8.1.0",
- "symfony/console": "^6.2|^7.0|^8.0"
+ "laravel/framework": "^11.35|^12.0|^13.0",
+ "php": "^8.2.0",
+ "symfony/console": "^7.0|^8.0"
},
"conflict": {
"laravel/boost": "<2.5.0"
},
"require-dev": {
- "guzzlehttp/guzzle": "^7.2",
+ "guzzlehttp/guzzle": "^7.15.2|^8.0",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.16",
"mockery/mockery": "^1.3.3",
- "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0",
- "phpunit/phpunit": "^10.4|^11.5|^12.0"
+ "orchestra/testbench": "^9.2|^10.0|^11.0",
+ "phpunit/phpunit": "^11.5|^12.0"
},
"suggest": {
"ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command."
@@ -1354,9 +1354,9 @@
],
"support": {
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
- "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.28"
+ "source": "https://github.com/inertiajs/inertia-laravel/tree/v3.5.1"
},
- "time": "2026-09-25T09:18:50+00:00"
+ "time": "2026-10-01T10:38:10+00:00"
},
{
"name": "jean85/pretty-package-versions",
diff --git a/config/inertia.php b/config/inertia.php
index 710685db..ff1156a5 100644
--- a/config/inertia.php
+++ b/config/inertia.php
@@ -1,55 +1,16 @@
[
- 'enabled' => false,
- 'url' => 'http://127.0.0.1:13714',
- // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
-
+ 'enabled' => env('INERTIA_SSR_ENABLED', false),
+ 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'),
+ ],
+ 'pages' => [
+ 'ensure_pages_exist' => false,
+ 'paths' => [resource_path('js/pages')],
+ 'extensions' => ['js', 'jsx', 'svelte', 'ts', 'tsx', 'vue'],
],
-
- /*
- |--------------------------------------------------------------------------
- | Testing
- |--------------------------------------------------------------------------
- |
- | The values described here are used to locate Inertia components on the
- | filesystem. For instance, when using `assertInertia`, the assertion
- | attempts to locate the component as a file relative to the paths.
- |
- */
-
'testing' => [
-
'ensure_pages_exist' => true,
-
- 'page_paths' => [
- resource_path('js/pages'),
- ],
-
- 'page_extensions' => [
- 'js',
- 'jsx',
- 'svelte',
- 'ts',
- 'tsx',
- 'vue',
- ],
-
],
-
];
diff --git a/config/tooling/eslint.config.js b/config/tooling/eslint.config.js
index cc9815f5..0b28d192 100644
--- a/config/tooling/eslint.config.js
+++ b/config/tooling/eslint.config.js
@@ -61,6 +61,7 @@ export default [
'docs/.vitepress/dist',
'docs/.vitepress/cache',
'coverage',
+ 'storage/validation',
],
},
prettier, // Turn off all rules that might conflict with Prettier
diff --git a/config/tooling/tsconfig.json b/config/tooling/tsconfig.json
index 380cd35f..4d361d12 100644
--- a/config/tooling/tsconfig.json
+++ b/config/tooling/tsconfig.json
@@ -19,5 +19,12 @@
},
"jsx": "react-jsx"
},
- "include": ["../../resources/js/**/*.ts", "../../resources/js/**/*.d.ts", "../../resources/js/**/*.tsx", "../../resources/themes/**/*.tsx"]
+ "include": [
+ "../../resources/js/**/*.ts",
+ "../../resources/js/**/*.d.ts",
+ "../../resources/js/**/*.tsx",
+ "../../resources/themes/**/*.tsx",
+ "../../tests/Fixtures/plugin-sdk/*.tsx",
+ "../../packages/plugin-sdk/ui.d.ts"
+ ]
}
diff --git a/config/tooling/vite.config.ts b/config/tooling/vite.config.ts
index 3a9b3039..0be1f79c 100644
--- a/config/tooling/vite.config.ts
+++ b/config/tooling/vite.config.ts
@@ -78,7 +78,7 @@ export default defineConfig(({ command, isSsrBuild }) => ({
port: 5173,
},
build: {
- target: process.env.VITE_BUILD_TARGET || 'es2020',
+ target: process.env.VITE_BUILD_TARGET || 'es2022',
minify: process.env.VITE_BUILD_MINIFY !== 'false',
sourcemap: process.env.VITE_BUILD_SOURCEMAP === 'true',
rolldownOptions: {
diff --git a/docs/plugin-frontend.md b/docs/plugin-frontend.md
index e3d9a893..3918690a 100644
--- a/docs/plugin-frontend.md
+++ b/docs/plugin-frontend.md
@@ -44,6 +44,13 @@ cannot resolve, so that recipe never worked.)
Use the preset in `packages/plugin-sdk` (`@modulo/plugin-sdk`):
+The current build uses Node 24 LTS, Vite 8, the React Vite plugin 6, and
+Inertia React 3. The preset also accepts Vite 6/7 configurations for existing
+plugin build pipelines. React and Inertia remain external imports: install
+updates to the core and its generated SDK shims together, then rebuild plugins
+independently. Existing component registration and import-map interfaces remain
+unchanged. Builds target ES2022; browsers must support that baseline.
+
```js
// vite.config.js in your plugin
import react from '@vitejs/plugin-react';
diff --git a/package-lock.json b/package-lock.json
index 3afc5d91..e75fdfbe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"@headlessui/react": "^2.2.10",
- "@inertiajs/react": "^2.1.4",
+ "@inertiajs/react": "^3.9.1",
"@radix-ui/react-avatar": "^1.2.7",
"@radix-ui/react-checkbox": "^1.3.12",
"@radix-ui/react-collapsible": "^1.1.21",
@@ -693,28 +693,37 @@
"license": "MIT"
},
"node_modules/@inertiajs/core": {
- "version": "2.3.10",
+ "version": "3.9.1",
+ "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.9.1.tgz",
+ "integrity": "sha512-ScLQZ1/KXlvmqCEJMEZABSK5updJZIli1kaG/coWXpWMmpSLsazSJkR7esITYp7X5vTgrOGNIrcRmdMp/GQ0NA==",
"license": "MIT",
"dependencies": {
- "@types/lodash-es": "^4.17.12",
- "axios": "^1.13.2",
- "laravel-precognition": "^1.0.0",
- "lodash-es": "^4.17.21",
- "qs": "^6.14.1"
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "es-toolkit": "^1.33.0",
+ "laravel-precognition": "^2.1.0"
+ },
+ "peerDependencies": {
+ "axios": "^1.15.2"
+ },
+ "peerDependenciesMeta": {
+ "axios": {
+ "optional": true
+ }
}
},
"node_modules/@inertiajs/react": {
- "version": "2.3.10",
+ "version": "3.9.1",
+ "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-3.9.1.tgz",
+ "integrity": "sha512-fRWElF5XIm1j01ecQCEehx0KPLEYVA6fCdWvig7XUhwhmSQG6EIyNum3eG8ZEfYuY7mWhNyqae/MSRIv+bAsjw==",
"license": "MIT",
"dependencies": {
- "@inertiajs/core": "2.3.10",
- "@types/lodash-es": "^4.17.12",
- "laravel-precognition": "^1.0.0",
- "lodash-es": "^4.17.21"
+ "@inertiajs/core": "3.9.1",
+ "es-toolkit": "^1.33.0",
+ "laravel-precognition": "^2.1.0"
},
"peerDependencies": {
- "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
@@ -3066,17 +3075,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/lodash": {
- "version": "4.17.23",
- "license": "MIT"
- },
- "node_modules/@types/lodash-es": {
- "version": "4.17.12",
- "license": "MIT",
- "dependencies": {
- "@types/lodash": "*"
- }
- },
"node_modules/@types/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
@@ -3884,18 +3882,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
- "node_modules/agent-base": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
- "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
- "license": "MIT",
- "dependencies": {
- "debug": "4"
- },
- "engines": {
- "node": ">= 6.0.0"
- }
- },
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -4124,12 +4110,6 @@
"node": ">= 0.4"
}
},
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "license": "MIT"
- },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"dev": true,
@@ -4144,18 +4124,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/axios": {
- "version": "1.20.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
- "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.16.0",
- "form-data": "^4.0.6",
- "https-proxy-agent": "^5.0.1",
- "proxy-from-env": "^2.1.0"
- }
- },
"node_modules/balanced-match": {
"version": "1.0.2",
"dev": true,
@@ -4211,6 +4179,7 @@
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -4222,6 +4191,7 @@
},
"node_modules/call-bound": {
"version": "1.0.4",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4364,18 +4334,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "license": "MIT",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -4559,6 +4517,7 @@
},
"node_modules/debug": {
"version": "4.4.3",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4616,15 +4575,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -4692,6 +4642,7 @@
},
"node_modules/dunder-proto": {
"version": "1.0.1",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -4804,6 +4755,7 @@
},
"node_modules/es-define-property": {
"version": "1.0.1",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4811,6 +4763,7 @@
},
"node_modules/es-errors": {
"version": "1.3.0",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4851,6 +4804,7 @@
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -4861,6 +4815,7 @@
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -4899,6 +4854,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es-toolkit": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
+ "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks",
+ "tests/types",
+ "tests/browser-compat"
+ ]
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5219,26 +5186,6 @@
"tabbable": "^6.5.0"
}
},
- "node_modules/follow-redirects": {
- "version": "1.16.0",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
- "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
"node_modules/for-each": {
"version": "0.3.5",
"dev": true,
@@ -5253,22 +5200,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/form-data": {
- "version": "4.0.6",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
- "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
- "license": "MIT",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.4",
- "mime-types": "^2.1.35"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5285,6 +5216,7 @@
},
"node_modules/function-bind": {
"version": "1.1.2",
+ "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -5350,6 +5282,7 @@
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -5379,6 +5312,7 @@
},
"node_modules/get-proto": {
"version": "1.0.1",
+ "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -5444,6 +5378,7 @@
},
"node_modules/gopd": {
"version": "1.2.0",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5504,6 +5439,7 @@
},
"node_modules/has-symbols": {
"version": "1.1.0",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5514,6 +5450,7 @@
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
+ "dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -5529,6 +5466,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -5606,19 +5544,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/https-proxy-agent": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
- "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
- "license": "MIT",
- "dependencies": {
- "agent-base": "6",
- "debug": "4"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/ignore": {
"version": "5.3.2",
"dev": true,
@@ -6183,11 +6108,20 @@
}
},
"node_modules/laravel-precognition": {
- "version": "1.0.0",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.1.0.tgz",
+ "integrity": "sha512-W9ZobjRAReiIawSWxXUPR6q+Bvq5seO6tn+scdURl/JKOFKveXvR4Sno2KxGqQt2cI81nh8Vt2cFlaOMqXqLNw==",
"license": "MIT",
"dependencies": {
- "axios": "^1.4.0",
- "lodash-es": "^4.17.21"
+ "es-toolkit": "^1.32.0"
+ },
+ "peerDependencies": {
+ "axios": "^1.4.0"
+ },
+ "peerDependenciesMeta": {
+ "axios": {
+ "optional": true
+ }
}
},
"node_modules/laravel-vite-plugin": {
@@ -6523,12 +6457,6 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
- "node_modules/lodash-es": {
- "version": "4.18.1",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
- "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
- "license": "MIT"
- },
"node_modules/lodash.merge": {
"version": "4.6.2",
"dev": true,
@@ -6593,6 +6521,7 @@
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6721,27 +6650,6 @@
],
"license": "MIT"
},
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -6774,6 +6682,7 @@
},
"node_modules/ms": {
"version": "2.1.3",
+ "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -6814,6 +6723,7 @@
},
"node_modules/object-inspect": {
"version": "1.13.4",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -7302,15 +7212,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/proxy-from-env": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
- "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/punycode": {
"version": "2.3.1",
"dev": true,
@@ -7328,22 +7229,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
- "node_modules/qs": {
- "version": "6.16.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
- "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "es-define-property": "^1.0.1",
- "side-channel": "^1.1.1"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/qs-esm": {
"version": "7.0.3",
"license": "BSD-3-Clause",
@@ -7818,6 +7703,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -7837,6 +7723,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -7851,6 +7738,7 @@
},
"node_modules/side-channel-map": {
"version": "1.0.1",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -7867,6 +7755,7 @@
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
diff --git a/package.json b/package.json
index 8f49eccd..3c48db22 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"build": "vite build --config config/tooling/vite.config.ts",
"build:ssr": "npm run build && vite build --ssr --config config/tooling/vite.config.ts",
"build:shims": "node scripts/build-plugin-shims.mjs",
+ "build:plugin-fixture": "vite build --config tests/Fixtures/plugin-sdk/vite.config.mjs",
"dev": "vite --config config/tooling/vite.config.ts",
"format": "prettier --write resources/ --ignore-path .gitignore --ignore-path config/tooling/prettier.ignore",
"format:check": "prettier --check resources/ --ignore-path .gitignore --ignore-path config/tooling/prettier.ignore",
@@ -57,7 +58,7 @@
},
"dependencies": {
"@headlessui/react": "^2.2.10",
- "@inertiajs/react": "^2.1.4",
+ "@inertiajs/react": "^3.9.1",
"@radix-ui/react-avatar": "^1.2.7",
"@radix-ui/react-checkbox": "^1.3.12",
"@radix-ui/react-collapsible": "^1.1.21",
diff --git a/packages/plugin-sdk/index.js b/packages/plugin-sdk/index.js
index 083bd0f3..d9e280e0 100644
--- a/packages/plugin-sdk/index.js
+++ b/packages/plugin-sdk/index.js
@@ -13,6 +13,8 @@
* export default moduloPlugin({ plugins: [react()] });
*/
+import { version as viteVersion } from 'vite';
+
/** Imports the core provides at runtime; never bundled into a plugin. */
export const SHARED = ['react', 'react-dom', 'react/jsx-runtime', '@inertiajs/react', '@modulo/ui'];
@@ -25,10 +27,12 @@ export const SHARED = ['react', 'react-dom', 'react/jsx-runtime', '@inertiajs/re
*/
export function moduloPlugin({ entry = 'resources/js/index.tsx', outDir = 'resources/dist', plugins = [] } = {}) {
return {
+ publicDir: false,
plugins,
// Built for the browser: keep process.env out of the bundle.
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
build: {
+ target: 'es2022',
outDir,
emptyOutDir: true,
lib: {
@@ -36,7 +40,7 @@ export function moduloPlugin({ entry = 'resources/js/index.tsx', outDir = 'resou
formats: ['es'],
fileName: () => 'plugin.js',
},
- rollupOptions: {
+ [Number.parseInt(viteVersion, 10) >= 8 ? 'rolldownOptions' : 'rollupOptions']: {
external: (id) => SHARED.includes(id) || SHARED.some((shared) => id.startsWith(`${shared}/`)),
},
},
diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json
index 4714f99c..cceabb31 100644
--- a/packages/plugin-sdk/package.json
+++ b/packages/plugin-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@modulo/plugin-sdk",
- "version": "1.2.0",
+ "version": "1.3.0",
"description": "Build a Modulo CMS plugin's React interface against the core's runtime.",
"type": "module",
"main": "index.js",
diff --git a/public/modulo-sdk/inertia-react.js b/public/modulo-sdk/inertia-react.js
index 09ed3a1c..5aaa6a51 100644
--- a/public/modulo-sdk/inertia-react.js
+++ b/public/modulo-sdk/inertia-react.js
@@ -7,13 +7,18 @@ export const Form = m.Form;
export const Head = m.Head;
export const InfiniteScroll = m.InfiniteScroll;
export const Link = m.Link;
+export const WhenMounted = m.WhenMounted;
export const WhenVisible = m.WhenVisible;
export const config = m.config;
export const createInertiaApp = m.createInertiaApp;
+export const http = m.http;
export const progress = m.progress;
+export const resetLayoutProps = m.resetLayoutProps;
export const router = m.router;
+export const setLayoutProps = m.setLayoutProps;
export const useForm = m.useForm;
export const useFormContext = m.useFormContext;
+export const useHttp = m.useHttp;
export const usePage = m.usePage;
export const usePoll = m.usePoll;
export const usePrefetch = m.usePrefetch;
diff --git a/resources/js/ApplicationProviders.tsx b/resources/js/ApplicationProviders.tsx
new file mode 100644
index 00000000..723f1f3c
--- /dev/null
+++ b/resources/js/ApplicationProviders.tsx
@@ -0,0 +1,12 @@
+import type { ReactNode } from 'react';
+import { AdminToastProvider } from './components/admin/AdminToastProvider';
+import ErrorBoundary from './ErrorBoundary';
+
+/** Keep the initial server and browser trees identical during hydration. */
+export default function ApplicationProviders({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/resources/js/app.tsx b/resources/js/app.tsx
index 9a5c953b..f4de87ae 100644
--- a/resources/js/app.tsx
+++ b/resources/js/app.tsx
@@ -1,11 +1,10 @@
import '../css/app.css';
-import { createInertiaApp } from '@inertiajs/react';
+import { createInertiaApp, type ResolvedComponent } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
-import { createElement, type ComponentType, type ReactNode } from 'react';
-import { createRoot } from 'react-dom/client';
-import ErrorBoundary from './ErrorBoundary';
-import { AdminToastProvider } from './components/admin/AdminToastProvider';
+import { createElement, type ReactNode } from 'react';
+import { createRoot, hydrateRoot } from 'react-dom/client';
+import ApplicationProviders from './ApplicationProviders';
import { initializeTheme } from './hooks/use-appearance';
import { resolvePluginComponent } from './plugin-runtime';
@@ -20,22 +19,22 @@ const appName = import.meta.env.VITE_APP_NAME || 'Modulo CMS';
// Page components (standardized to a single `pages/` directory)
// Use lazy imports to avoid evaluating every page at startup
-const pages = import.meta.glob(['./pages/**/*.tsx', '!./pages/**/*.test.tsx'], { eager: false });
-const themeComponents = import.meta.glob(['../themes/**/components/**/*.tsx', '!../themes/**/*.test.tsx'], { eager: false });
+const pages = import.meta.glob(['./pages/**/*.tsx', '!./pages/**/*.test.tsx'], { eager: false });
+const themeComponents = import.meta.glob(['../themes/**/components/**/*.tsx', '!../themes/**/*.test.tsx'], { eager: false });
-type PageModule = { default: ComponentType & { layout?: unknown } };
+type PageModule = { default: ResolvedComponent };
/**
* A plugin's admin screen gets the admin frame (sidebar, header, toasts) unless
* it brings its own layout, so plugins needn't import it.
*/
-async function withAdminLayout(module: PageModule): Promise {
+async function withAdminLayout(module: PageModule): Promise {
const page = module.default;
if (page.layout === undefined && /^\/dashboard(\/|$)/.test(window.location.pathname)) {
const { default: AdminLayout } = await import('./layouts/admin-layout');
page.layout = (content: ReactNode) => createElement(AdminLayout, null, content);
}
- return module;
+ return page;
}
createInertiaApp({
@@ -72,13 +71,13 @@ createInertiaApp({
const themeComponentPath = `../themes/${themeSlug}/components/${componentPath}.tsx`;
if (themeComponents[themeComponentPath]) {
- return resolvePageComponent(themeComponentPath, themeComponents);
+ return resolvePageComponent(themeComponentPath, themeComponents).then((module) => module.default);
}
// Try index.tsx for directory-based components
const indexPath = `../themes/${themeSlug}/components/${componentPath}/index.tsx`;
if (themeComponents[indexPath]) {
- return resolvePageComponent(indexPath, themeComponents);
+ return resolvePageComponent(indexPath, themeComponents).then((module) => module.default);
}
}
@@ -93,7 +92,7 @@ createInertiaApp({
// Try to find the first matching path
for (const path of possiblePaths) {
if (pages[path]) {
- return resolvePageComponent(path, pages);
+ return resolvePageComponent(path, pages).then((module) => module.default);
}
}
@@ -101,7 +100,6 @@ createInertiaApp({
throw new Error(`Page not found: ./pages/${name}.tsx or ./pages/${name}/index.tsx or theme component ${name}`);
},
setup({ el, App, props }) {
- const root = createRoot(el);
// Attach global error hooks for visibility
if (typeof window !== 'undefined') {
window.addEventListener('error', (e) => {
@@ -113,13 +111,16 @@ createInertiaApp({
}
// Minimal initial render with global ErrorBoundary
- root.render(
-
-
-
-
- ,
+ const content = (
+
+
+
);
+ if (el.hasChildNodes()) {
+ hydrateRoot(el, content);
+ } else {
+ createRoot(el).render(content);
+ }
},
progress: {
color: '#4B5563',
diff --git a/resources/js/ssr.tsx b/resources/js/ssr.tsx
index 2091431d..847bbf89 100644
--- a/resources/js/ssr.tsx
+++ b/resources/js/ssr.tsx
@@ -1,27 +1,32 @@
-import { createInertiaApp } from '@inertiajs/react';
+import { createInertiaApp, type ResolvedComponent } from '@inertiajs/react';
import createServer from '@inertiajs/react/server';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import ReactDOMServer from 'react-dom/server';
import { route } from 'ziggy-js';
+import ApplicationProviders from './ApplicationProviders';
import type { SharedData } from './types';
const appName = import.meta.env.VITE_APP_NAME || 'Modulo CMS';
-const pages = import.meta.glob(['./pages/**/*.tsx', '!./pages/**/*.test.tsx'], { eager: false });
-const themeComponents = import.meta.glob(['../themes/**/components/**/*.tsx', '!../themes/**/*.test.tsx'], { eager: false });
+type PageModule = { default: ResolvedComponent };
+const pages = import.meta.glob(['./pages/**/*.tsx', '!./pages/**/*.test.tsx'], { eager: false });
+const themeComponents = import.meta.glob(['../themes/**/components/**/*.tsx', '!../themes/**/*.test.tsx'], { eager: false });
createServer((page) =>
createInertiaApp({
page,
render: ReactDOMServer.renderToString,
- title: (title) => (title ? `${title} - ${appName}` : appName),
+ title: (title) => {
+ const admin = /^\/(dashboard|settings)(\/|$)/.test(page.url);
+ return !title ? appName : admin ? `${title} - ${appName}` : title;
+ },
resolve: (name) => {
if (name.startsWith('Plugins/')) {
// Plugin components are client-only: their bundle is fetched
// over HTTP and registers itself on window, and this runs in
// Node with neither. Render nothing and let hydration fill it
// in, rather than failing the whole page.
- return Promise.resolve({ default: () => null });
+ return Promise.resolve(() => null);
}
if (name.startsWith('Themes/')) {
@@ -35,12 +40,12 @@ createServer((page) =>
const themeComponentPath = `../themes/${themeSlug}/components/${componentPath}.tsx`;
if (themeComponents[themeComponentPath]) {
- return resolvePageComponent(themeComponentPath, themeComponents);
+ return resolvePageComponent(themeComponentPath, themeComponents).then((module) => module.default);
}
const indexPath = `../themes/${themeSlug}/components/${componentPath}/index.tsx`;
if (themeComponents[indexPath]) {
- return resolvePageComponent(indexPath, themeComponents);
+ return resolvePageComponent(indexPath, themeComponents).then((module) => module.default);
}
}
@@ -52,7 +57,7 @@ createServer((page) =>
for (const path of possiblePaths) {
if (pages[path]) {
- return resolvePageComponent(path, pages);
+ return resolvePageComponent(path, pages).then((module) => module.default);
}
}
@@ -66,7 +71,11 @@ createServer((page) =>
route(name, params, absolute, { ...ziggy, location: new URL(ziggy.location) }),
});
- return ;
+ return (
+
+
+
+ );
},
}),
);
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
index e8c30422..05f4b0a9 100644
--- a/resources/views/app.blade.php
+++ b/resources/views/app.blade.php
@@ -49,39 +49,39 @@ function gtag(){dataLayer.push(arguments);}
@isset($pageMeta)
{{-- Public pages: the tags the theme sets in the browser, for crawlers and link previews.
- `inertia` marks them for the theme to replace on load. --}}
- {{ $pageMeta['title'] }}
+ `data-inertia` marks them for the theme to replace on load. --}}
+ {{ $pageMeta['title'] }}
@if($pageMeta['description'])
-
+
@endif
-
-
-
+
+
+
@if($pageMeta['description'])
-
-
+
+
@endif
-
-
-
+
+
+
@if($pageMeta['image'])
-
-
+
+
@endif
@if($pageMeta['published'])
-
+
@endif
-
-
- {{-- Not marked `inertia`: the theme doesn't manage these, so they stay for crawlers that render --}}
+
+
+ {{-- Not marked `data-inertia`: the theme doesn't manage these, so they stay for crawlers that render --}}
@foreach($pageMeta['alternates'] as $hreflang => $href)
@endforeach
@foreach($pageMeta['json_ld'] as $schema)
-
+
@endforeach
@else
- {{ config('app.name', 'Laravel') }}
+ {{ config('app.name', 'Laravel') }}
@endisset
diff --git a/scripts/check-ssr.mjs b/scripts/check-ssr.mjs
new file mode 100644
index 00000000..cabbeab9
--- /dev/null
+++ b/scripts/check-ssr.mjs
@@ -0,0 +1,49 @@
+import assert from 'node:assert/strict';
+
+const ziggy = {
+ url: 'http://localhost',
+ port: null,
+ defaults: {},
+ location: 'http://localhost/login',
+ routes: Object.fromEntries(
+ [
+ ['home', '/'],
+ ['login', 'login'],
+ ['register', 'register'],
+ ['password.request', 'forgot-password'],
+ ].map(([name, uri]) => [name, { uri, methods: ['GET', 'HEAD'] }]),
+ ),
+};
+const base = {
+ url: '/login',
+ version: null,
+ clearHistory: false,
+ encryptHistory: false,
+ rememberedState: {},
+ props: { errors: {}, ziggy, auth: { user: null } },
+};
+for (const [component, props, expected] of [
+ ['auth/login', { canResetPassword: true }, 'Log in to your account'],
+ [
+ 'Themes/ModernReact/Page',
+ {
+ page: { id: 1, title: 'SSR theme probe', slug: 'ssr-probe', content: 'Rendered on the server
', published_at: '', updated_at: '' },
+ site: { name: 'SSR site' },
+ menus: {},
+ theme: {},
+ },
+ 'Rendered on the server',
+ ],
+]) {
+ const response = await fetch('http://127.0.0.1:13714/render', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...base, component, props: { ...base.props, ...props } }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ assert.equal(response.status, 200, await response.clone().text());
+ const rendered = await response.json();
+ assert.ok(rendered.body.includes(expected), `Missing rendered content for ${component}`);
+ assert.ok(Array.isArray(rendered.head), 'SSR must return the document head');
+ console.log(`PASS: SSR renders ${component}`);
+}
diff --git a/tests/Feature/Frontend/ServerMetaTest.php b/tests/Feature/Frontend/ServerMetaTest.php
index 6f419f13..b0fd3899 100644
--- a/tests/Feature/Frontend/ServerMetaTest.php
+++ b/tests/Feature/Frontend/ServerMetaTest.php
@@ -14,7 +14,7 @@
*/
function jsonLdIn(string $html): array
{
- preg_match_all('##s', $html, $m);
+ preg_match_all('##s', $html, $m);
return array_map(fn ($json) => json_decode($json, true), $m[1]);
}
@@ -29,11 +29,11 @@ function jsonLdIn(string $html): array
$html = $this->get('/news/launch')->assertOk()->getContent();
- expect($html)->toContain('Launch day | Smoke Site')
- ->toContain('')
- ->toContain('')
- ->toContain('')
- ->toContain('')
+ expect($html)->toContain('Launch day | Smoke Site')
+ ->toContain('')
+ ->toContain('')
+ ->toContain('')
+ ->toContain('')
->toContain('content="index, follow');
expect(jsonLdIn($html)[0])->toMatchArray(['@type' => 'Article', 'headline' => 'Launch day | Smoke Site', 'url' => url('/news/launch')]);
@@ -44,8 +44,8 @@ function jsonLdIn(string $html): array
$html = $this->get('/hidden')->assertOk()->getContent();
- expect($html)->toContain('')
- ->toContain('');
+ expect($html)->toContain('')
+ ->toContain('');
});
it('escapes titles and keeps json-ld from closing its script tag', function () {
diff --git a/tests/Feature/SecurityHeadersTest.php b/tests/Feature/SecurityHeadersTest.php
index 54ded4c1..fe5f5e63 100644
--- a/tests/Feature/SecurityHeadersTest.php
+++ b/tests/Feature/SecurityHeadersTest.php
@@ -1,5 +1,7 @@
'enforce']);
@@ -16,9 +18,10 @@
preg_match("/'nonce-([^']+)'/", $policy, $m);
expect($m[1] ?? null)->not->toBeNull();
- // Every inline script in the page carries that nonce.
+ // Executable inline scripts carry the nonce. Inertia 3's application/json
+ // page payload is a data block, not executable JavaScript.
$html = $response->getContent();
- preg_match_all('/';
+ Inertia::share('security_probe', $probe);
+
+ $html = $this->get('/login')->assertOk()->getContent();
+ expect($html)->not->toContain($probe);
+ preg_match('/