diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d496749..d7d93777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A theme's `resources/views/patterns` can hold a plain `.html` file alongside `.blade.php` ones. It is registered verbatim, with no compilation step — for a pattern that needs no PHP, such as one exported straight from the block editor. Its header (`Title`, `Slug`, `Categories`…) follows the same fenced-comment convention as a Blade pattern's, `` instead of `{{-- \nTitle: ... \n --}}` ### Fixed +- A Vite script was printed before WordPress's import map, which Firefox and Safari then ignore: any WordPress script module on the page — the navigation block's, the search block's, the image lightbox's — failed on `@wordpress/interactivity was a bare specifier`, so the block did nothing. Chromium tolerates the order, which hid it. In a classic theme the import map is always in the footer, so any theme with a Vite script in the head was affected as soon as an author inserted such a block. The Vite client of the dev server had the same problem - A block theme's own `404.html` answered with HTTP 200. WordPress core resolves it to `wp-includes/template-canvas.php`, which is never a Blade view, so it always rendered through `FrontendController`'s raw-PHP-template branch — the only branch that never looked at `is_404()`. Measured on a fresh block theme: right content, wrong status - A real 404 lost its `error404` body class, and every page served by the template hierarchy carried a meaningless one built from its path (`any-no-such-page`). The `WordPressBodyClass` middleware was meant for Laravel routes, which WordPress's own resolution calls a 404, but it only ran on WordPress routes — the `{any}` fallback included — where WordPress's verdict is the right one. So it did the opposite of its job on both sides: a Laravel route (`Route::get('/dashboard/{tab}')`) kept `error404`, `is_404()` true and a "Page not found" title over its 200 response ### Changed - The `WordPressBodyClass` middleware is replaced by a `RouteMatched` listener, `ApplyApplicationRouteContext`, which runs on every route: a route WordPress answers (`Route::wp()` and the template-hierarchy fallback, both flagged `isWordPressRoute()`) keeps WordPress's classes and verdict untouched; any other route has `is_404()` cleared and its URI segments added as body classes (`dashboard tab-settings`). A middleware could not do this — Laravel routes are not given the WordPress middleware stack +- On the front end and in the admin, a Vite script is enqueued as a WordPress script module (`wp_enqueue_script_module`), so WordPress places it after its import map, as it does its own modules: in the head of a block theme (the footer with `loadInFooter()`), always in the footer of a classic theme. What a module cannot take — `dependencies()`, `localize()`, `inline()` — goes on a classic companion script, `{handle}-data`, which runs before the module. The editor, login screen and Customizer are unchanged ## [v13.32.0-beta.9](https://github.com/Pollora/framework/compare/v13.32.0-beta.8...v13.32.0-beta.9) - 2026-09-28 diff --git a/src/Asset/Domain/Contracts/ViteManagerInterface.php b/src/Asset/Domain/Contracts/ViteManagerInterface.php index 4199abbc..27521941 100755 --- a/src/Asset/Domain/Contracts/ViteManagerInterface.php +++ b/src/Asset/Domain/Contracts/ViteManagerInterface.php @@ -47,4 +47,9 @@ public function isRunningHot(): bool; * @return string The HTML script tag for Vite client */ public function getViteClientHtml(): string; + + /** + * The URL of the Vite client on the dev server, or an empty string when Vite is not running hot. + */ + public function clientUrl(): string; } diff --git a/src/Asset/Domain/Models/ViteManager.php b/src/Asset/Domain/Models/ViteManager.php index a947fd86..312852c9 100755 --- a/src/Asset/Domain/Models/ViteManager.php +++ b/src/Asset/Domain/Models/ViteManager.php @@ -53,4 +53,12 @@ public function getViteClientHtml(): string { return ''; } + + /** + * Returns an empty string for the Vite client URL (stub). + */ + public function clientUrl(): string + { + return ''; + } } diff --git a/src/Asset/Infrastructure/Services/AssetEnqueuer.php b/src/Asset/Infrastructure/Services/AssetEnqueuer.php index d15cf367..0d9e3989 100755 --- a/src/Asset/Infrastructure/Services/AssetEnqueuer.php +++ b/src/Asset/Infrastructure/Services/AssetEnqueuer.php @@ -22,6 +22,21 @@ */ class AssetEnqueuer { + /** + * Hooks on whose pages WordPress prints script modules, after its import map. + * + * A Vite entry is an ES module. Enqueued as a classic script it was printed in + * the head, before the import map — which Firefox and Safari then ignore, so any + * WordPress module on the page (the navigation block's, for one) failed to + * resolve `@wordpress/interactivity`. Enqueued as a script module, WordPress + * places it itself: after the import map, in the head of a block theme (or its + * footer with loadInFooter()), always in the footer of a classic theme. + * + * The editor, the login screen and the Customizer print no script modules, so + * Vite entries there stay classic scripts. + */ + private const array SCRIPT_MODULE_HOOKS = ['wp_enqueue_scripts', 'admin_enqueue_scripts']; + /** * The asset path or array of paths. * @@ -336,7 +351,7 @@ public function __destruct() $this->loadViteClient($hook); } - resolve(HookAction::class)->add($hook, $this->enqueueStyleOrScript(...), 99); + resolve(HookAction::class)->add($hook, fn () => $this->enqueueStyleOrScript($hook), 99); } } catch (\Throwable $throwable) { Log::error('Error in AssetEnqueuer::__destruct', ['error' => $throwable->getMessage(), 'hooks' => $this->hooks, 'path' => $this->path ?? null]); @@ -346,12 +361,12 @@ public function __destruct() /** * Enqueues all styles and scripts for the current asset. */ - public function enqueueStyleOrScript(): void + public function enqueueStyleOrScript(?string $hook = null): void { $paths = $this->getAssetPaths(); foreach ($paths as $type => $pathList) { foreach ($pathList as $path) { - $this->enqueueAsset((string) $type, $this->forceFullUrl($path)); + $this->enqueueAsset((string) $type, $this->forceFullUrl($path), $hook); } } } @@ -374,6 +389,19 @@ protected function addHook(string $hook): self */ protected function loadViteClient(string $hook): void { + if ($this->printsScriptModules($hook)) { + // The client is a module too: printed ahead of the import map, it would void it. + resolve(HookAction::class)->add($hook, function (): void { + $url = $this->viteManager instanceof ViteManager ? $this->viteManager->clientUrl() : ''; + + if ($url !== '') { + wp_enqueue_script_module('vite-client/'.md5($url), $url); + } + }, 1); + + return; + } + resolve(HookAction::class)->add($hook, function (): void { if ($this->viteManager instanceof ViteManager && $this->viteManager->isRunningHot()) { echo $this->viteManager->getViteClientHtml(); @@ -431,12 +459,14 @@ protected function getAssetPaths(): array * * @throws \InvalidArgumentException When asset type is not supported */ - protected function enqueueAsset(string $type, string $path): void + protected function enqueueAsset(string $type, string $path, ?string $hook = null): void { $handle = $this->useVite && ! $this->viteManager->isRunningHot() ? $this->handle.'/'.sanitize_title(basename($path)) : $this->handle; match ($type) { 'css' => $this->enqueueStyle($path, $handle), - 'js' => $this->enqueueScript($path, $handle), + 'js' => $this->useVite && $this->printsScriptModules($hook) + ? $this->enqueueScriptModule($path, $handle) + : $this->enqueueScript($path, $handle), default => throw new \InvalidArgumentException('Unsupported asset type: '.$type) }; } @@ -467,6 +497,52 @@ protected function enqueueScript(string $path, string $handle): void } } + /** + * Whether a Vite entry enqueued on this hook is printed by WordPress as a script module. + */ + protected function printsScriptModules(?string $hook): bool + { + return in_array($hook, self::SCRIPT_MODULE_HOOKS, true) && function_exists('wp_enqueue_script_module'); + } + + /** + * Enqueues a Vite entry as a WordPress script module. + * + * A module can only depend on modules, and takes no localized data or inline + * script. What the asset declares of those goes on a classic companion script, + * `{handle}-data`, printed in the head: it runs before the module, which the + * browser defers. + */ + protected function enqueueScriptModule(string $path, string $handle): void + { + // null, not false: false appends WordPress's version, and a module is identified by its + // exact URL — a chunk importing the entry back would load a second copy of it. + wp_enqueue_script_module($handle, $path, [], $this->version, ['in_footer' => $this->loadInFooter]); + + resolve(HookFilter::class)->add('wp_script_attributes', fn (array $attributes): array => ($attributes['id'] ?? null) === $handle.'-js-module' + ? [...$attributes, 'crossorigin' => true] + : $attributes); + + $hasInlineContent = ! in_array($this->inlineContent, [null, '', '0'], true); + + if ($this->dependencies === [] && $this->localizationData === [] && ! $hasInlineContent) { + return; + } + + $companion = $handle.'-data'; + // No source: a registered handle that prints only its dependencies and inline data. + wp_register_script($companion, false, $this->dependencies, $this->version, false); + wp_enqueue_script($companion); + + foreach ($this->localizationData as $objectName => $data) { + wp_localize_script($companion, $objectName, $data); + } + + if ($hasInlineContent) { + wp_add_inline_script($companion, $this->inlineContent, $this->inlinePosition); + } + } + /** * Enqueues a CSS file with WordPress. * diff --git a/src/Asset/Infrastructure/Services/ViteManager.php b/src/Asset/Infrastructure/Services/ViteManager.php index 15aa8769..9bd42416 100755 --- a/src/Asset/Infrastructure/Services/ViteManager.php +++ b/src/Asset/Infrastructure/Services/ViteManager.php @@ -99,6 +99,14 @@ public function asset(string $path): string return $this->getViteInstance()->asset($this->container()->getBasePath().$path); } + /** + * The URL of the Vite client on the dev server, or an empty string when Vite is not running hot. + */ + public function clientUrl(): string + { + return $this->isRunningHot() ? $this->getViteInstance()->asset('@vite/client') : ''; + } + /** * Checks if Vite is running in hot module replacement mode. * diff --git a/tests/Feature/Asset/AssetEnqueuerTest.php b/tests/Feature/Asset/AssetEnqueuerTest.php index e4de7b22..431e1c7e 100644 --- a/tests/Feature/Asset/AssetEnqueuerTest.php +++ b/tests/Feature/Asset/AssetEnqueuerTest.php @@ -2,11 +2,14 @@ declare(strict_types=1); +use Brain\Monkey\Functions; use Pollora\Application\Application\Services\ConsoleDetectionService; use Pollora\Application\Domain\Contracts\ConsoleDetectorInterface; use Pollora\Asset\Application\Services\AssetManager; use Pollora\Asset\Infrastructure\Services\AssetEnqueuer; +use Pollora\Asset\Infrastructure\Services\ViteManager; use Pollora\Hook\Domain\Contract\Action as HookAction; +use Pollora\Hook\Domain\Contract\Filter as HookFilter; beforeEach(function (): void { // Bind console detection to always return true (prevents WP calls) @@ -225,6 +228,132 @@ }); }); + describe('Vite entries as script modules', function (): void { + beforeEach(function (): void { + $this->filters = []; + $hookFilter = Mockery::mock(HookFilter::class); + $hookFilter->shouldReceive('add')->andReturnUsing(function (string $hook, Closure $callback) use ($hookFilter): HookFilter { + $this->filters[$hook] = $callback; + + return $hookFilter; + }); + $this->app->instance(HookFilter::class, $hookFilter); + + $this->modules = []; + $this->scripts = []; + $this->registered = []; + Functions\when('wp_enqueue_script_module')->alias(function (string $id, string $src = '', array $deps = [], $version = false, array $args = []): void { + $this->modules[$id] = ['src' => $src, 'deps' => $deps, 'version' => $version, 'args' => $args]; + }); + Functions\when('wp_register_script')->alias(function (string $handle, $src, array $deps = []): void { + $this->registered[$handle] = ['src' => $src, 'deps' => $deps]; + }); + Functions\when('wp_enqueue_script')->alias(function (string $handle, $src = '', array $deps = []): void { + $this->scripts[$handle] = ['src' => $src, 'deps' => $deps]; + }); + Functions\when('sanitize_title')->alias(fn (string $title): string => strtolower(str_replace('.', '-', $title))); + + $this->viteManager = Mockery::mock(ViteManager::class); + $this->viteManager->shouldReceive('isRunningHot')->andReturn(false)->byDefault(); + $this->viteManager->shouldReceive('getAssetUrls')->andReturn([])->byDefault(); + }); + + /** An enqueuer for a built Vite entry, set up the way useVite() would outside the console. */ + function viteEnqueuer(ViteManager $viteManager, array $settings = []): AssetEnqueuer + { + $enqueuer = resolve(AssetEnqueuer::class)->handle('buzz/script'); + + foreach (['useVite' => true, 'viteManager' => $viteManager, 'path' => ['js' => ['https://site.test/build/app-abc.js']], ...$settings] as $property => $value) { + (new ReflectionProperty($enqueuer, $property))->setValue($enqueuer, $value); + } + + return $enqueuer; + } + + it('enqueues the entry as a module on the front end, with no version appended', function (): void { + viteEnqueuer($this->viteManager)->enqueueStyleOrScript('wp_enqueue_scripts'); + + expect($this->modules)->toHaveKey('buzz/script/app-abc-js') + ->and($this->modules['buzz/script/app-abc-js']) + ->toMatchArray(['src' => 'https://site.test/build/app-abc.js', 'version' => null, 'args' => ['in_footer' => false]]) + ->and($this->scripts)->toBe([]); + }); + + it('passes loadInFooter() on as in_footer, as WordPress reads it for its own modules', function (): void { + viteEnqueuer($this->viteManager, ['loadInFooter' => true])->enqueueStyleOrScript('wp_enqueue_scripts'); + + expect($this->modules['buzz/script/app-abc-js']['args'])->toBe(['in_footer' => true]); + }); + + it('enqueues the entry as a module in the admin', function (): void { + viteEnqueuer($this->viteManager)->enqueueStyleOrScript('admin_enqueue_scripts'); + + expect($this->modules)->toHaveKey('buzz/script/app-abc-js'); + }); + + it('keeps a classic script where WordPress prints no modules, such as the editor', function (): void { + viteEnqueuer($this->viteManager)->enqueueStyleOrScript('enqueue_block_editor_assets'); + + expect($this->modules)->toBe([]) + ->and($this->scripts)->toHaveKey('buzz/script/app-abc-js'); + }); + + it('marks the module tag crossorigin, and no other tag', function (): void { + viteEnqueuer($this->viteManager)->enqueueStyleOrScript('wp_enqueue_scripts'); + + $filter = $this->filters['wp_script_attributes']; + + expect($filter(['id' => 'buzz/script/app-abc-js-js-module']))->toHaveKey('crossorigin', true) + ->and($filter(['id' => 'something-else-js-module']))->not->toHaveKey('crossorigin'); + }); + + it('puts dependencies, localized data and inline script on a classic companion', function (): void { + $localized = []; + $inline = []; + Functions\when('wp_localize_script')->alias(function (string $handle, string $name) use (&$localized): void { + $localized[$handle][] = $name; + }); + Functions\when('wp_add_inline_script')->alias(function (string $handle, string $code) use (&$inline): void { + $inline[$handle] = $code; + }); + + viteEnqueuer($this->viteManager, [ + 'dependencies' => ['jquery'], + 'localizationData' => ['buzzData' => ['a' => 1]], + 'inlineContent' => 'window.ready = true;', + ])->enqueueStyleOrScript('wp_enqueue_scripts'); + + expect($this->modules['buzz/script/app-abc-js']['deps'])->toBe([]) + ->and($this->registered['buzz/script/app-abc-js-data'])->toBe(['src' => false, 'deps' => ['jquery']]) + ->and($this->scripts)->toHaveKey('buzz/script/app-abc-js-data') + ->and($localized)->toBe(['buzz/script/app-abc-js-data' => ['buzzData']]) + ->and($inline)->toBe(['buzz/script/app-abc-js-data' => 'window.ready = true;']); + }); + + it('enqueues the Vite client as a module while Vite runs hot', function (): void { + $this->viteManager->shouldReceive('isRunningHot')->andReturn(true); + $this->viteManager->shouldReceive('clientUrl')->andReturn('https://site.test:5173/@vite/client'); + + $callbacks = []; + $hookAction = $this->hookAction; + $hookAction->shouldReceive('add')->andReturnUsing(function (string $hook, callable $callback, int $priority = 10) use (&$callbacks, $hookAction): HookAction { + $callbacks[] = [$hook, $priority, $callback]; + + return $hookAction; + }); + + (function (): void { + $this->loadViteClient('wp_enqueue_scripts'); + })->call(viteEnqueuer($this->viteManager)); + + [$hook, $priority, $callback] = $callbacks[0]; + $callback(); + + expect([$hook, $priority])->toBe(['wp_enqueue_scripts', 1]) + ->and(array_column($this->modules, 'src'))->toBe(['https://site.test:5173/@vite/client']); + }); + }); + describe('full chain', function (): void { it('supports complete fluent configuration', function (): void { $enqueuer = $this->app->make(AssetEnqueuer::class); diff --git a/tests/e2e/specs/script-modules.spec.ts b/tests/e2e/specs/script-modules.spec.ts new file mode 100644 index 00000000..0eda6029 --- /dev/null +++ b/tests/e2e/specs/script-modules.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { runId } from '../support/site'; + +/** + * The active theme's Vite entry and WordPress's own script modules, on one page. + * + * A Vite entry is an ES module. Printed before WordPress's import map, it voids the map + * in Firefox and Safari: every WordPress module on the page — the navigation block's, + * here — then fails on `@wordpress/interactivity`. Chromium tolerates the order, so the + * order itself is asserted from the HTML, in every browser. + */ + +// Named, as the theme may have a navigation block of its own. +const navigation = ''; + +test('the theme entry comes after the import map, and WordPress modules run', async ({ page, requestUtils }) => { + const post = await requestUtils.rest({ + method: 'POST', + path: '/wp/v2/posts', + data: { title: `E2E script modules ${runId}`, content: navigation, status: 'publish' }, + }); + + try { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(post.link); + const html = await page.content(); + + const importMap = html.search(/]*\btype="importmap"/); + expect(importMap, 'the navigation block puts an import map on the page').toBeGreaterThan(-1); + + const themeEntries = [...html.matchAll(/]*\bsrc="[^"]*\/build\/theme\/[^"]*"[^>]*>/g)]; + test.skip(themeEntries.length === 0, 'the active theme loads no Vite script'); + + for (const entry of themeEntries) { + expect(entry[0], 'the theme entry is a module').toContain('type="module"'); + expect(entry.index, 'the theme entry is printed after the import map').toBeGreaterThan(importMap); + } + + const menu = page.locator('.e2e-script-modules'); + await menu.locator('.wp-block-navigation__responsive-container-open').click(); + await expect(menu.locator('.wp-block-navigation__responsive-container')).toHaveClass(/\bis-menu-open\b/); + expect(errors, 'no uncaught page error').toEqual([]); + } finally { + await requestUtils.rest({ method: 'DELETE', path: `/wp/v2/posts/${post.id}`, params: { force: true } }); + } +});