Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<!-- \nTitle: ... \n -->` 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

Expand Down
5 changes: 5 additions & 0 deletions src/Asset/Domain/Contracts/ViteManagerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 8 additions & 0 deletions src/Asset/Domain/Models/ViteManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,12 @@ public function getViteClientHtml(): string
{
return '';
}

/**
* Returns an empty string for the Vite client URL (stub).
*/
public function clientUrl(): string
{
return '';
}
}
86 changes: 81 additions & 5 deletions src/Asset/Infrastructure/Services/AssetEnqueuer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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]);
Expand All @@ -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);
}
}
}
Expand All @@ -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();
Expand Down Expand Up @@ -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)
};
}
Expand Down Expand Up @@ -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.
*
Expand Down
8 changes: 8 additions & 0 deletions src/Asset/Infrastructure/Services/ViteManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
129 changes: 129 additions & 0 deletions tests/Feature/Asset/AssetEnqueuerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions tests/e2e/specs/script-modules.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = '<!-- wp:navigation {"overlayMenu":"always","className":"e2e-script-modules"} --><!-- wp:page-list /--><!-- /wp: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(/<script\b[^>]*\btype="importmap"/);
expect(importMap, 'the navigation block puts an import map on the page').toBeGreaterThan(-1);

const themeEntries = [...html.matchAll(/<script\b[^>]*\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 } });
}
});
Loading