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('/]*\bsrc=)[^>]*>/', $html, $inline); + preg_match_all('/]*\bsrc=)(?![^>]*type="application\/json")[^>]*>/i', $html, $inline); foreach ($inline[0] as $tag) { expect($tag)->toContain('nonce="'.$m[1].'"'); } @@ -33,6 +36,18 @@ ->and($response->headers->has('Content-Security-Policy'))->toBeFalse(); }); +it('escapes executable markup inside the initial page data block', function () { + $probe = ''; + Inertia::share('security_probe', $probe); + + $html = $this->get('/login')->assertOk()->getContent(); + expect($html)->not->toContain($probe); + preg_match('/]*type="application\/json"[^>]*>(.*?)<\/script>/s', $html, $payload); + expect($payload[1] ?? null)->not->toBeNull(); + $page = json_decode($payload[1], true, flags: JSON_THROW_ON_ERROR); + expect($page['props']['security_probe'])->toBe($probe); +}); + it('sends HSTS over HTTPS only', function () { expect($this->get('/login')->headers->has('Strict-Transport-Security'))->toBeFalse() ->and($this->get('https://localhost/login')->headers->get('Strict-Transport-Security'))->toContain('max-age='); diff --git a/tests/Fixtures/plugin-sdk/vite.config.mjs b/tests/Fixtures/plugin-sdk/vite.config.mjs new file mode 100644 index 00000000..95d3a6be --- /dev/null +++ b/tests/Fixtures/plugin-sdk/vite.config.mjs @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { moduloPlugin } from '../../../packages/plugin-sdk/index.js'; + +export default moduloPlugin({ + entry: 'tests/Fixtures/plugin-sdk/widget.tsx', + outDir: 'storage/validation/plugin-sdk', + plugins: [react()], +}); diff --git a/tests/Fixtures/plugin-sdk/widget.tsx b/tests/Fixtures/plugin-sdk/widget.tsx new file mode 100644 index 00000000..44ee033c --- /dev/null +++ b/tests/Fixtures/plugin-sdk/widget.tsx @@ -0,0 +1,17 @@ +import { Head, WhenMounted } from '@inertiajs/react'; +import { Button } from '@modulo/ui'; +import { useState } from 'react'; + +// Independently compiled plugin fixture exercises the shared runtime imports, +// including an export introduced by Inertia 3. +export function Widget() { + const [count, setCount] = useState(0); + return ( + <> + + + + + + ); +} diff --git a/tests/e2e/ssr.spec.ts b/tests/e2e/ssr.spec.ts new file mode 100644 index 00000000..6106b6db --- /dev/null +++ b/tests/e2e/ssr.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; + +test.skip(process.env.MODULO_E2E_SSR !== '1', 'Requires the isolated SSR-enabled server'); + +test('server-rendered pages remain readable without JavaScript and hydrate without errors', async ({ browser, page }) => { + const staticContext = await browser.newContext({ javaScriptEnabled: false }); + const staticPage = await staticContext.newPage(); + await staticPage.goto('/login'); + await expect(staticPage.locator('#app')).toContainText('Log in'); + await expect(staticPage.getByLabel('Email address')).toBeVisible(); + await staticContext.close(); + + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.goto('/login'); + await page.getByLabel('Email address').fill('ssr@example.test'); + await expect(page.getByLabel('Email address')).toHaveValue('ssr@example.test'); + await page.goto('/'); + await expect(page.locator('main')).toBeVisible(); + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/toasts.spec.ts b/tests/e2e/toasts.spec.ts index ad87bd21..ec5937f7 100644 --- a/tests/e2e/toasts.spec.ts +++ b/tests/e2e/toasts.spec.ts @@ -11,7 +11,8 @@ for (const width of [1280, 390]) { const doc = new DOMParser().parseFromString(html, 'text/html'); const app = doc.querySelector('#app'); if (!app) throw new Error('Application shell is missing'); - const data = JSON.parse(app.getAttribute('data-page') || '{}'); + const pageData = doc.querySelector('script[data-page="app"][type="application/json"]'); + const data = JSON.parse(pageData?.textContent || app.getAttribute('data-page') || '{}'); data.component = 'Themes/ModernReact/Shop/Archive'; data.url = '/shop'; data.props.products = { @@ -20,7 +21,8 @@ for (const width of [1280, 390]) { { id: 2, title: 'Second product', slug: 'second-product', price: 20, in_stock: true }, ], }; - app.setAttribute('data-page', JSON.stringify(data)); + if (pageData) pageData.textContent = JSON.stringify(data); + else app.setAttribute('data-page', JSON.stringify(data)); return '' + doc.documentElement.outerHTML; }, html); await page.route('**/shop', (route) => route.fulfill({ contentType: 'text/html', body: fixtureHtml })); diff --git a/tests/e2e/z-demo-shop.spec.ts b/tests/e2e/z-demo-shop.spec.ts index a40169a6..16346913 100644 --- a/tests/e2e/z-demo-shop.spec.ts +++ b/tests/e2e/z-demo-shop.spec.ts @@ -56,16 +56,12 @@ test('demo shop search, sale price, sold out state, cart toast and checkout work button.click(); button.click(); }); - releaseCheckout(); - await expect(page).toHaveURL(/\/shop\/order\//); - await expect(page.locator('main')).toContainText('Ceramic Mug — Sand'); - await expect(page.locator('main')).not.toContainText('Order not found'); - expect(checkoutRequests).toBe(1); const payload = original.postDataJSON(); const csrf = original.headers()['x-csrf-token']; const headers = { Accept: 'application/json', ...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}) }; - // Two genuine requests with the same session and key must return the - // original order even though the first submission has emptied the cart. + // The browser submission remains gated while two genuine first requests + // race on the server. CI uses four PHP workers and the shared file cache. + // They must create one order; the released browser request then replays it. const repeats = await Promise.all([ page.context().request.post('/shop/checkout', { data: payload, headers }), page.context().request.post('/shop/checkout', { data: payload, headers }), @@ -73,6 +69,11 @@ test('demo shop search, sale price, sold out state, cart toast and checkout work for (const response of repeats) expect(response.status()).toBe(200); const [first, second] = await Promise.all(repeats.map((response) => response.json())); expect(first.order.id).toBe(second.order.id); + releaseCheckout(); + await expect(page).toHaveURL(/\/shop\/order\//); + await expect(page.locator('main')).toContainText('Ceramic Mug — Sand'); + await expect(page.locator('main')).not.toContainText('Order not found'); + expect(checkoutRequests).toBe(1); expect(new URL(first.redirect, page.url()).href).toBe(page.url()); expect(errors).toEqual([]); }); diff --git a/tests/e2e/z-editor.spec.ts b/tests/e2e/z-editor.spec.ts index ed768835..fbb4ee92 100644 --- a/tests/e2e/z-editor.spec.ts +++ b/tests/e2e/z-editor.spec.ts @@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process'; const EMAIL = process.env.MODULO_E2E_EMAIL ?? 'e2e-admin@example.test'; const PASSWORD = process.env.MODULO_E2E_PASSWORD ?? 'a-sufficiently-long-password'; +const hasFixtures = Boolean(process.env.MODULO_E2E_CONTAINER) || process.env.MODULO_E2E_FIXTURES === 'true'; async function login(page: Page, email = EMAIL) { await page.goto('/login'); @@ -31,14 +32,20 @@ test.describe('editor submissions and recovery', () => { test.beforeAll(() => { const container = process.env.MODULO_E2E_CONTAINER; if (container) { - execFileSync('podman', ['exec', '-e', 'MODULO_E2E_FIXTURES=true', container, 'php', 'tests/e2e/editor-fixtures.php'], { - env: { ...process.env, MODULO_E2E_FIXTURES: 'true' }, - }); + execFileSync( + process.env.MODULO_CONTAINER_RUNTIME ?? 'docker', + ['exec', '-e', 'MODULO_E2E_FIXTURES=true', container, 'php', 'tests/e2e/editor-fixtures.php'], + { + env: { ...process.env, MODULO_E2E_FIXTURES: 'true' }, + }, + ); + } else if (hasFixtures) { + execFileSync('php', ['tests/e2e/editor-fixtures.php'], { env: process.env }); } }); test('writers can save drafts without publication controls and cannot read another user recovery', async ({ page, browser }) => { - test.skip(!process.env.MODULO_E2E_CONTAINER, 'Requires the isolated writer fixture.'); + test.skip(!hasFixtures, 'Requires the isolated writer fixture.'); await login(page); await newPost(page, `Private administrator recovery ${Date.now()}`); await expect(page.getByRole('status').filter({ hasText: /saved at/i })).toBeVisible(); @@ -58,7 +65,7 @@ test.describe('editor submissions and recovery', () => { }); test('concurrent tabs show a recoverable revision conflict', async ({ page, context }) => { - test.skip(!process.env.MODULO_E2E_CONTAINER, 'Requires the isolated multi-tab fixture.'); + test.skip(!hasFixtures, 'Requires the isolated multi-tab fixture.'); await login(page); await newPost(page, `Concurrent work ${Date.now()}`); await expect(page.getByRole('status').filter({ hasText: /saved at/i })).toBeVisible(); @@ -84,7 +91,7 @@ test.describe('editor submissions and recovery', () => { }); test('translation recovery is separate and the editor works on mobile', async ({ page }) => { - test.skip(!process.env.MODULO_E2E_CONTAINER, 'Requires the isolated translation fixture.'); + test.skip(!hasFixtures, 'Requires the isolated translation fixture.'); await page.setViewportSize({ width: 390, height: 844 }); await login(page); const englishTitle = `English recovery ${Date.now()}`;