diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 308eb36a..47f81783 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -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; } /** diff --git a/src/Plugins/Tia/InertiaEdges.php b/src/Plugins/Tia/InertiaEdges.php index fdf14c75..e1c921c9 100644 --- a/src/Plugins/Tia/InertiaEdges.php +++ b/src/Plugins/Tia/InertiaEdges.php @@ -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 `
`. 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 — ``. 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 — `
…`. 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 + // `#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']) diff --git a/src/Plugins/Tia/JsImportParser.php b/src/Plugins/Tia/JsImportParser.php index 4b655641..cfc87ac6 100644 --- a/src/Plugins/Tia/JsImportParser.php +++ b/src/Plugins/Tia/JsImportParser.php @@ -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> */ 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 []; } diff --git a/src/Plugins/Tia/JsModuleGraph.php b/src/Plugins/Tia/JsModuleGraph.php index f79acf61..d2776475 100644 --- a/src/Plugins/Tia/JsModuleGraph.php +++ b/src/Plugins/Tia/JsModuleGraph.php @@ -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(); diff --git a/src/Plugins/Tia/TableTracker.php b/src/Plugins/Tia/TableTracker.php index 1c85902f..3ac66163 100644 --- a/src/Plugins/Tia/TableTracker.php +++ b/src/Plugins/Tia/TableTracker.php @@ -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); } } diff --git a/src/Plugins/Tia/WatchDefaults/Inertia.php b/src/Plugins/Tia/WatchDefaults/Inertia.php index aebf80c7..54a9d55a 100644 --- a/src/Plugins/Tia/WatchDefaults/Inertia.php +++ b/src/Plugins/Tia/WatchDefaults/Inertia.php @@ -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; } }