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;
}
}