This commit is contained in:
nuno maduro
2026-04-27 16:56:27 +01:00
parent 81bfdbf8fe
commit d4c7362132
6 changed files with 180 additions and 74 deletions

View File

@ -829,36 +829,45 @@ final class Graph
/**
* Maps a project-relative path to its Inertia component name if it
* lives under `resources/js/Pages/` with a recognised framework
* extension. Returns null otherwise so callers can cheaply ignore
* non-page files. Matches Inertia's resolver convention: strip the
* `resources/js/Pages/` prefix, strip the extension, preserve the
* remaining slashes (`Users/Show.vue` → `Users/Show`).
* lives under the project's pages directory with a recognised
* framework extension. Returns null otherwise so callers can
* cheaply ignore non-page files. Matches Inertia's resolver
* convention: strip the pages prefix, strip the extension, preserve
* the remaining slashes (`Users/Show.vue` → `Users/Show`).
*
* Both `resources/js/Pages/` (the classic Inertia-Vue convention)
* and `resources/js/pages/` (the Laravel React starter kit, and
* other lowercase-by-default setups) are accepted — paths from
* git are case-sensitive on Linux, so we must match the exact
* casing used by the project rather than picking one and forcing
* the other to fall through to the broad watch pattern.
*/
private function componentForInertiaPage(string $rel): ?string
{
$prefix = 'resources/js/Pages/';
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
if (! str_starts_with($rel, $prefix)) {
continue;
}
if (! str_starts_with($rel, $prefix)) {
return null;
$tail = substr($rel, strlen($prefix));
$dot = strrpos($tail, '.');
if ($dot === false) {
return null;
}
$extension = substr($tail, $dot + 1);
if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'], true)) {
return null;
}
$name = substr($tail, 0, $dot);
return $name === '' ? null : $name;
}
$tail = substr($rel, strlen($prefix));
$dot = strrpos($tail, '.');
if ($dot === false) {
return null;
}
$extension = substr($tail, $dot + 1);
if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'], true)) {
return null;
}
$name = substr($tail, 0, $dot);
return $name === '' ? null : $name;
return null;
}
/**

View File

@ -33,7 +33,15 @@ final class InertiaEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string REQUEST_HANDLED_EVENT = '\\Illuminate\\Foundation\\Http\\Events\\RequestHandled';
/**
* Event class name used as the listener key. Stored *without* a
* leading backslash because Laravel's `Dispatcher` keys
* `$listeners[$eventName]` by the literal string passed to
* `listen()`, and looks up incoming events by their PHP-class
* name (`get_class($event)`), which never has a leading
* backslash. A `\Illuminate\…` key would silently never match.
*/
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
/**
* App-scoped marker that makes `arm()` idempotent across per-test
@ -129,22 +137,66 @@ final class InertiaEdges
}
}
// Initial-load HTML path: Inertia embeds the page payload in a
// `data-page` attribute on the root `<div id="app">`. We only
// pay the regex cost when the body actually contains the
// attribute, so non-Inertia HTML responses are effectively a
// no-op.
// Initial-load HTML path. Inertia ships two shapes here and
// we honour both:
//
// 1. SSR-safe script tag — `<script data-page="app"
// type="application/json">{…JSON…}</script>`. The
// Laravel React starter kit (and modern Inertia-React)
// use this so the JSON survives server-rendered
// hydration without HTML-encoding the payload into an
// attribute. The `data-page="app"` *attribute value* is
// the literal string `"app"` — only the tag *body*
// carries the page JSON.
// 2. Classic — `<div id="app" data-page="{…JSON…}">…`. Older
// Inertia-Vue and Inertia-React still emit this. Here
// `data-page` IS the JSON, HTML-entity-encoded.
//
// Try the script-tag shape first; if the response uses it,
// the classic regex would also see a `data-page="app"` token
// and try to JSON-decode the literal string `"app"`.
$content = self::readContent($response);
if ($content === null || ! str_contains($content, 'data-page=')) {
if ($content === null) {
return null;
}
if (preg_match('/\sdata-page="([^"]+)"/', $content, $match) !== 1) {
return null;
// Lookahead pair handles arbitrary attribute order on the
// `<script>` tag.
if (str_contains($content, 'type="application/json"')
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1]));
if ($component !== null) {
return $component;
}
}
$decoded = json_decode(html_entity_decode($match[1]), true);
// Classic: only accept a value that looks like a JSON object
// (`{…}`). Avoids matching the script-tag form's
// `data-page="app"` attribute when both shapes coexist.
if (str_contains($content, 'data-page=')
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1]));
if ($component !== null) {
return $component;
}
}
return null;
}
/**
* Parses an Inertia page JSON blob and returns the `component`
* field if it's a non-empty string. Used by both the script-tag
* and the `data-page`-attribute paths so the success criteria are
* identical.
*/
private static function componentFromJson(string $json): ?string
{
/** @var mixed $decoded */
$decoded = json_decode($json, true);
if (is_array($decoded)
&& isset($decoded['component'])

View File

@ -36,23 +36,32 @@ final class JsImportParser
private const array RESOLVABLE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js', 'mjs', 'mts'];
private const string PAGES_DIR = 'resources/js/Pages';
private const string JS_DIR = 'resources/js';
/**
* Walks `resources/js/Pages` and, for each page, collects its
* transitive file imports. Returns the inverted graph so callers
* can look up "what pages depend on this shared file".
* Walks the project's pages directory (`resources/js/Pages` or its
* lowercase Laravel-React-starter-kit equivalent `resources/js/pages`)
* and, for each page, collects its transitive file imports. Returns
* the inverted graph so callers can look up "what pages depend on
* this shared file".
*
* @return array<string, list<string>>
*/
public static function parse(string $projectRoot): array
{
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR;
$pagesRoot = $projectRoot.DIRECTORY_SEPARATOR.self::PAGES_DIR;
$pagesRoot = null;
if (! is_dir($pagesRoot)) {
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.$candidate;
if (is_dir($abs)) {
$pagesRoot = $abs;
break;
}
}
if ($pagesRoot === null) {
return [];
}

View File

@ -76,7 +76,22 @@ final class JsModuleGraph
*/
public static function isApplicable(string $projectRoot): bool
{
return self::hasViteConfig($projectRoot) && is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.'Pages');
if (! self::hasViteConfig($projectRoot)) {
return false;
}
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React
// starter kit (`pages/`) conventions are accepted — projects
// running on a case-sensitive filesystem (Linux CI) get
// exactly one of the two, and we shouldn't refuse to walk the
// graph based on which one it picks.
foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
return true;
}
}
return false;
}
/**
@ -104,7 +119,21 @@ final class JsModuleGraph
return null;
}
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
// Tell the Node helper which casing this project uses for its
// pages directory. The helper defaults to `resources/js/Pages`;
// the Laravel React starter ships lowercase `resources/js/pages`,
// and on a case-sensitive filesystem the helper would otherwise
// walk a non-existent directory and emit an empty module graph.
$env = [];
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$env['TIA_VITE_PAGES_DIR'] = $candidate;
break;
}
}
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot, $env);
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
$process->run();

View File

@ -118,6 +118,11 @@ final class TableTracker
return;
}
$events->listen('\\Illuminate\\Database\\Events\\QueryExecuted', $listener);
// Event class key intentionally has no leading backslash —
// `Dispatcher::listen()` stores by the literal string and the
// lookup at dispatch time uses `get_class($event)` (no
// leading backslash), so a `\Illuminate\…` key would never
// match the fired event.
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
}
}

View File

@ -30,37 +30,39 @@ final readonly class Inertia implements WatchDefault
? $testPath.'/Browser'
: $testPath;
return [
// Inertia page components (React / Vue / Svelte). Scoped to
// `$browserDir` only — a Vue/React edit cannot change the
// output of a server-side Inertia test (those assert on the
// component *name* returned by `Inertia::render()`, not its
// client-side implementation). Broad invalidation is only
// meaningful for tests that actually render the DOM. Precise
// per-component edges come from `InertiaEdges` at record
// time and replace this fallback when available.
'resources/js/Pages/**/*.vue' => [$browserDir],
'resources/js/Pages/**/*.tsx' => [$browserDir],
'resources/js/Pages/**/*.jsx' => [$browserDir],
'resources/js/Pages/**/*.svelte' => [$browserDir],
'resources/js/Pages/**/*.ts' => [$browserDir],
'resources/js/Pages/**/*.js' => [$browserDir],
// Inertia page components (React / Vue / Svelte). Scoped to
// `$browserDir` only — a Vue/React edit cannot change the
// output of a server-side Inertia test (those assert on the
// component *name* returned by `Inertia::render()`, not its
// client-side implementation). Broad invalidation is only
// meaningful for tests that actually render the DOM. Precise
// per-component edges come from `InertiaEdges` at record
// time and replace this fallback when available.
//
// Both `Pages/` (classic Inertia-Vue) and `pages/` (Laravel
// React starter kit, and other lowercase-by-default setups)
// are emitted — paths from git are case-sensitive on Linux,
// so a single casing would silently miss the other convention.
$patterns = [];
// Shared layouts / components consumed by pages.
'resources/js/Layouts/**/*.vue' => [$browserDir],
'resources/js/Layouts/**/*.tsx' => [$browserDir],
'resources/js/Layouts/**/*.ts' => [$browserDir],
'resources/js/Layouts/**/*.js' => [$browserDir],
'resources/js/Components/**/*.vue' => [$browserDir],
'resources/js/Components/**/*.tsx' => [$browserDir],
'resources/js/Components/**/*.ts' => [$browserDir],
'resources/js/Components/**/*.js' => [$browserDir],
foreach (['Pages', 'pages'] as $pages) {
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
$patterns["resources/js/{$pages}/**/*.{$ext}"] = [$browserDir];
}
}
// SSR entry point.
'resources/js/ssr.js' => [$browserDir],
'resources/js/ssr.ts' => [$browserDir],
'resources/js/app.js' => [$browserDir],
'resources/js/app.ts' => [$browserDir],
];
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
$patterns["resources/js/{$shared}/**/*.{$ext}"] = [$browserDir];
}
}
// SSR entry point.
$patterns['resources/js/ssr.js'] = [$browserDir];
$patterns['resources/js/ssr.ts'] = [$browserDir];
$patterns['resources/js/app.js'] = [$browserDir];
$patterns['resources/js/app.ts'] = [$browserDir];
return $patterns;
}
}