From 3d3c5d41acc2d3139e23e5645e35f220772f0e70 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 23 Apr 2026 12:29:24 -0700 Subject: [PATCH] wip --- bin/pest-tia-vite-deps.mjs | 181 +++++++++++++ src/Concerns/Testable.php | 2 + src/Plugins/Tia.php | 33 ++- src/Plugins/Tia/Fingerprint.php | 14 +- src/Plugins/Tia/Graph.php | 302 ++++++++++++++++++++++ src/Plugins/Tia/InertiaEdges.php | 170 ++++++++++++ src/Plugins/Tia/JsImportParser.php | 270 +++++++++++++++++++ src/Plugins/Tia/JsModuleGraph.php | 142 ++++++++++ src/Plugins/Tia/Recorder.php | 54 ++++ src/Plugins/Tia/WatchDefaults/Inertia.php | 17 +- 10 files changed, 1176 insertions(+), 9 deletions(-) create mode 100644 bin/pest-tia-vite-deps.mjs create mode 100644 src/Plugins/Tia/InertiaEdges.php create mode 100644 src/Plugins/Tia/JsImportParser.php create mode 100644 src/Plugins/Tia/JsModuleGraph.php diff --git a/bin/pest-tia-vite-deps.mjs b/bin/pest-tia-vite-deps.mjs new file mode 100644 index 00000000..8f29feda --- /dev/null +++ b/bin/pest-tia-vite-deps.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +/** + * TIA Vite dependency resolver. + * + * Spins up a throwaway headless Vite dev server using the project's + * `vite.config.*`, walks every `resources/js/Pages/**` entry to warm + * up the module graph, then serializes the graph as a reverse map: + * + * { "": ["", ...], ... } + * + * The resulting JSON is written to stdout. Stderr is silent on + * success so Pest can parse stdout without stripping. + * + * Why this exists: at TIA record time we need to know which Inertia + * page components depend on each shared source file (Button.vue, + * Layouts/*.vue, etc.) so a later edit to one of those files can + * invalidate only the tests that rendered an affected page. Vite + * already knows this via its module graph — we borrow it. + * + * Called from `Pest\Plugins\Tia\JsModuleGraph::build()` as: + * + * node bin/pest-tia-vite-deps.mjs + * + * Environment: + * TIA_VITE_PAGES_DIR override the `resources/js/Pages` default. + * TIA_VITE_TIMEOUT_MS override the 20s internal watchdog. + */ + +import { readdir } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { resolve, relative, extname, posix, sep, join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte']) +const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd()) +const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/') +const TIMEOUT_MS = Number.parseInt(process.env.TIA_VITE_TIMEOUT_MS ?? '20000', 10) + +// Resolve Vite from the project's own `node_modules`, not from this +// helper's location (which lives under `vendor/pestphp/pest/bin/` and +// has no `node_modules`). `createRequire` anchored at the project +// root walks up from there, matching the resolution behaviour any +// project-local script would see. +async function loadVite() { + const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json')) + const vitePath = projectRequire.resolve('vite') + return await import(pathToFileURL(vitePath).href) +} + +const { createServer } = await loadVite() + +async function listPageFiles(pagesDir) { + if (!existsSync(pagesDir)) return [] + + const out = [] + const walk = async (dir) => { + let entries + try { entries = await readdir(dir, { withFileTypes: true }) } catch { return } + for (const entry of entries) { + const full = resolve(dir, entry.name) + if (entry.isDirectory()) { await walk(full); continue } + if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full) + } + } + + await walk(pagesDir) + return out +} + +function componentNameFor(pageAbs, pagesDir) { + const rel = relative(pagesDir, pageAbs).split(sep).join('/') + const ext = extname(rel) + return rel.slice(0, rel.length - ext.length) +} + +async function main() { + const pagesDir = resolve(PROJECT_ROOT, PAGES_REL) + const pages = await listPageFiles(pagesDir) + + if (pages.length === 0) { + process.stdout.write('{}') + return + } + + // Boot Vite in middleware mode (no port binding, no HMR server). + // We only need the module graph; transformRequest per page warms + // it without running a bundle. + const server = await createServer({ + configFile: undefined, // auto-detect vite.config.* + root: PROJECT_ROOT, + logLevel: 'silent', + clearScreen: false, + server: { + middlewareMode: true, + hmr: false, + watch: null, + }, + appType: 'custom', + optimizeDeps: { disabled: true }, + }) + + // Watchdog — don't let a pathological config hang the record run. + const killer = setTimeout(() => { + server.close().catch(() => {}).finally(() => process.exit(2)) + }, TIMEOUT_MS) + + // Reverse map: depSourcePath → Set. + const reverse = new Map() + + const pageComponentCache = new Map() + for (const page of pages) { + pageComponentCache.set(page, componentNameFor(page, pagesDir)) + } + + try { + for (const pagePath of pages) { + const pageComponent = pageComponentCache.get(pagePath) + const pageUrl = '/' + posix.relative( + PROJECT_ROOT.split(sep).join('/'), + pagePath.split(sep).join('/'), + ) + + try { + await server.transformRequest(pageUrl, { ssr: false }) + } catch { + // Transform errors (missing deps, syntax issues) shouldn't + // poison the whole graph — skip this page and continue. + continue + } + + const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false) + if (!pageModule) continue + + // BFS over importedModules, scoped to files inside the project. + const visited = new Set() + const queue = [pageModule] + while (queue.length) { + const mod = queue.shift() + for (const imported of mod.importedModules) { + const id = imported.file ?? imported.id + if (!id || visited.has(id)) continue + visited.add(id) + + // Skip files outside the project root (node_modules, etc.) + // and virtual modules (`\0`-prefixed ids from plugins). + if (id.startsWith('\0')) continue + if (!id.startsWith(PROJECT_ROOT)) continue + + const rel = relative(PROJECT_ROOT, id).split(sep).join('/') + const bucket = reverse.get(rel) ?? new Set() + bucket.add(pageComponent) + reverse.set(rel, bucket) + + queue.push(imported) + } + } + } + } finally { + clearTimeout(killer) + await server.close() + } + + const payload = Object.create(null) + const keys = [...reverse.keys()].sort() + for (const key of keys) { + payload[key] = [...reverse.get(key)].sort() + } + + process.stdout.write(JSON.stringify(payload)) +} + +try { + // Node 20 dynamic-import path — some environments are pickier than others. + void pathToFileURL // retained to silence tree-shakers referencing the import + await main() +} catch (err) { + process.stderr.write(String(err?.stack ?? err ?? 'unknown error')) + process.exit(1) +} diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 64df3658..5657c4d8 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -9,6 +9,7 @@ use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Panic; use Pest\Plugins\Tia; use Pest\Plugins\Tia\BladeEdges; +use Pest\Plugins\Tia\InertiaEdges; use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\TableTracker; use Pest\Preset; @@ -328,6 +329,7 @@ trait Testable if ($recorder instanceof Recorder) { BladeEdges::arm($recorder); TableTracker::arm($recorder); + InertiaEdges::arm($recorder); } $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index c6e2f60c..c293eb60 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -14,6 +14,7 @@ use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\CoverageCollector; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; +use Pest\Plugins\Tia\JsModuleGraph; use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\ResultCollector; use Pest\Plugins\Tia\WatchPatterns; @@ -413,9 +414,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } $perTestTables = $recorder->perTestTables(); + $perTestInertia = $recorder->perTestInertiaComponents(); if (Parallel::isWorker()) { - $this->flushWorkerPartial($perTest, $perTestTables); + $this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia); $recorder->reset(); $this->coverageCollector->reset(); @@ -439,6 +441,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable ); $graph->replaceEdges($perTest); $graph->replaceTestTables($perTestTables); + $graph->replaceTestInertiaComponents($perTestInertia); + $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->pruneMissingTests(); // Fold in the results collected during this same record run. The @@ -527,6 +531,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $mergedFiles = []; $mergedTables = []; + $mergedInertia = []; foreach ($partialKeys as $key) { $data = $this->readPartial($key); @@ -555,6 +560,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } } + foreach ($data['inertia'] as $testFile => $components) { + if (! isset($mergedInertia[$testFile])) { + $mergedInertia[$testFile] = []; + } + + foreach ($components as $component) { + $mergedInertia[$testFile][$component] = true; + } + } + $this->state->delete($key); } @@ -570,6 +585,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $finalisedTables[$testFile] = array_keys($tableSet); } + $finalisedInertia = []; + + foreach ($mergedInertia as $testFile => $componentSet) { + $finalisedInertia[$testFile] = array_keys($componentSet); + } + // Empty-edges guard: if every worker returned no edges it almost // always means the coverage driver wasn't loaded in the workers // (common footgun with custom PHP ini scan dirs, Herd profiles, @@ -588,6 +609,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $graph->replaceEdges($finalised); $graph->replaceTestTables($finalisedTables); + $graph->replaceTestInertiaComponents($finalisedInertia); + $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->pruneMissingTests(); if (! $this->saveGraph($graph)) { @@ -979,12 +1002,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable /** * @param array> $perTestFiles * @param array> $perTestTables + * @param array> $perTestInertiaComponents */ - private function flushWorkerPartial(array $perTestFiles, array $perTestTables): void + private function flushWorkerPartial(array $perTestFiles, array $perTestTables, array $perTestInertiaComponents): void { $json = json_encode([ 'files' => $perTestFiles, 'tables' => $perTestTables, + 'inertia' => $perTestInertiaComponents, ], JSON_UNESCAPED_SLASHES); if ($json === false) { @@ -1122,7 +1147,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } /** - * @return array{files: array>, tables: array>}|null + * @return array{files: array>, tables: array>, inertia: array>}|null */ private function readPartial(string $key): ?array { @@ -1140,10 +1165,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $filesSource = is_array($data['files'] ?? null) ? $data['files'] : []; $tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : []; + $inertiaSource = is_array($data['inertia'] ?? null) ? $data['inertia'] : []; return [ 'files' => $this->cleanPartialSection($filesSource), 'tables' => $this->cleanPartialSection($tablesSource), + 'inertia' => $this->cleanPartialSection($inertiaSource), ]; } diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index a570b26a..aa53eee1 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -40,7 +40,19 @@ final readonly class Fingerprint // coverage, which would leave every DB test invalidated by // any migration change — force a rebuild so the new edges // are populated. - private const int SCHEMA_VERSION = 6; + // v7: Graph gained per-test Inertia page-component edges + // (`$testInertiaComponents`) for surgical page-file + // invalidation. Worker partial now includes an `inertia` + // section. Old graphs have no component edges; without a + // rebuild Vue/React page edits would fall through to the + // broad watch pattern even when precise matching could have + // worked. + // v8: Graph gained `$jsFileToComponents` — reverse dependency + // map computed at record time from Vite's module graph (or + // the PHP fallback) so shared components / layouts / + // composables invalidate the specific pages they're used + // by, not every browser test. + private const int SCHEMA_VERSION = 8; /** * @return array{ diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 350be7c5..a1c6f1e4 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -58,6 +58,33 @@ final class Graph */ private array $testTables = []; + /** + * Inertia page component edges: test file (relative) → list of + * component names the test server-side rendered (whatever was + * passed to `Inertia::render($component, …)`). Populated from + * `Recorder::perTestInertiaComponents()`; consumed at replay time + * so an edit to `resources/js/Pages/Users/Show.vue` only invalidates + * tests that rendered `Users/Show`. Same string-keyed shape as + * `$testTables` for the same diff-readable reasons. + * + * @var array> + */ + private array $testInertiaComponents = []; + + /** + * Inverted JS dependency map: project-relative source path under + * `resources/js/**` → list of Inertia page components that + * transitively import it. Populated at record time by + * `JsModuleGraph::build()` (Vite module graph via Node helper, + * with a PHP fallback). Replay uses this to route a + * `Components/Button.vue` edit directly to the pages that depend + * on it, intersecting against `$testInertiaComponents` for + * surgical invalidation. + * + * @var array> + */ + private array $jsFileToComponents = []; + /** * Environment fingerprint captured at record time. * @@ -199,6 +226,93 @@ final class Graph } } + // Inertia page-component routing. When a Vue/React/Svelte page + // under `resources/js/Pages/` changes, map it to the component + // name Inertia would use (the path relative to `Pages/`, with + // the extension stripped) and intersect with the captured + // component edges. Only invalidates tests that actually + // rendered the page. Pages with no captured edges (never + // rendered during record, brand-new on this branch) fall + // through to the watch-pattern fallback via + // `$unknownPageComponents` — safe over-run. + $changedComponents = []; + $unknownPageComponents = []; + + foreach ($nonMigrationPaths as $rel) { + $component = $this->componentForInertiaPage($rel); + + if ($component === null) { + continue; + } + + if ($this->anyTestUses($this->testInertiaComponents, $component)) { + $changedComponents[$component] = true; + } else { + $unknownPageComponents[] = $rel; + } + } + + // Pages whose component already resolved precisely via the + // direct Inertia edges path must not leak back through any + // broader mechanism (either the JS-dep lookup below, or the + // watch pattern further down). + $preciselyHandledPages = []; + foreach ($nonMigrationPaths as $rel) { + $component = $this->componentForInertiaPage($rel); + + if ($component !== null && isset($changedComponents[$component])) { + $preciselyHandledPages[$rel] = true; + } + } + + // Shared JS files (Components, Layouts, composables, etc.) + // aren't Inertia pages but pages depend on them transitively. + // `$jsFileToComponents` was computed at record time by walking + // Vite's module graph, so a change to + // `resources/js/Components/Button.vue` resolves directly to + // the set of page components that import it. Union those into + // `$changedComponents`. Files that aren't in the JS dep map + // fall through to the watch pattern below — same safety-net + // path the Inertia block above uses for unresolved pages. + $sharedFilesResolved = []; + foreach ($nonMigrationPaths as $rel) { + if (isset($preciselyHandledPages[$rel])) { + continue; + } + + if (! isset($this->jsFileToComponents[$rel])) { + continue; + } + + $touchedAny = false; + foreach ($this->jsFileToComponents[$rel] as $pageComponent) { + if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) { + $changedComponents[$pageComponent] = true; + $touchedAny = true; + } + } + + if ($touchedAny) { + $sharedFilesResolved[$rel] = true; + } + } + + if ($changedComponents !== []) { + foreach ($this->testInertiaComponents as $testFile => $components) { + if (isset($affectedSet[$testFile])) { + continue; + } + + foreach ($components as $component) { + if (isset($changedComponents[$component])) { + $affectedSet[$testFile] = true; + + break; + } + } + } + } + // 1. Coverage-edge lookup (PHP → PHP). Migrations are already // handled above; skipping them here prevents their always-on // coverage edges from invalidating the whole DB suite. @@ -241,8 +355,19 @@ final class Graph // (exotic syntax, raw SQL) are funneled back in here too so // broad invalidation still kicks in for edge cases we can't // parse. + // Exclude paths that were already routed precisely through + // either the Inertia page-component path or the shared-JS + // dependency path. Broadcasting them again via the watch + // pattern would re-add every test the pattern maps to, + // defeating the surgical match. $unknownToGraph = $unparseableMigrations; foreach ($nonMigrationPaths as $rel) { + if (isset($preciselyHandledPages[$rel])) { + continue; + } + if (isset($sharedFilesResolved[$rel])) { + continue; + } if (! isset($this->fileIds[$rel])) { $unknownToGraph[] = $rel; } @@ -521,6 +646,76 @@ final class Graph } } + /** + * Replaces Inertia component edges for the given test files. Names + * preserve case (they're identifiers like `Users/Show`, not + * user-supplied strings) but duplicates are collapsed. Same + * partial-update policy as `replaceTestTables`. + * + * @param array> $testToComponents + */ + public function replaceTestInertiaComponents(array $testToComponents): void + { + foreach ($testToComponents as $testFile => $components) { + $testRel = $this->relative($testFile); + + if ($testRel === null) { + continue; + } + + $normalised = []; + + foreach ($components as $component) { + if ($component !== '') { + $normalised[$component] = true; + } + } + + $names = array_keys($normalised); + sort($names); + + $this->testInertiaComponents[$testRel] = $names; + } + } + + /** + * Replaces the whole JS dep map. Called at record time with the + * output of `JsModuleGraph::build()`. Unlike the test-level + * replacements above this is a wholesale overwrite — the + * resolver produces the full graph on every run. + * + * @param array> $fileToComponents + */ + public function replaceJsFileToComponents(array $fileToComponents): void + { + $out = []; + + foreach ($fileToComponents as $path => $components) { + if ($path === '') { + continue; + } + $names = []; + + foreach ($components as $component) { + if ($component !== '') { + $names[$component] = true; + } + } + + if ($names === []) { + continue; + } + + $keys = array_keys($names); + sort($keys); + $out[$path] = $keys; + } + + ksort($out); + + $this->jsFileToComponents = $out; + } + /** * Projects under Laravel conventionally keep migrations at * `database/migrations/`. We recognise the directory as a prefix @@ -559,6 +754,58 @@ final class Graph return TableExtractor::fromMigrationSource($content); } + /** + * 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`). + */ + private function componentForInertiaPage(string $rel): ?string + { + $prefix = 'resources/js/Pages/'; + + 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'], true)) { + return null; + } + + $name = substr($tail, 0, $dot); + + return $name === '' ? null : $name; + } + + /** + * Whether any test's component set contains `$component`. Used to + * decide between precise edge matching and watch-pattern fallback + * for a changed Inertia page file. + * + * @param array> $edges + */ + private function anyTestUses(array $edges, string $component): bool + { + foreach ($edges as $components) { + if (in_array($component, $components, true)) { + return true; + } + } + + return false; + } + /** * Drops edges whose test file no longer exists on disk. Prevents the graph * from keeping stale entries for deleted / renamed tests that would later @@ -574,6 +821,12 @@ final class Graph } } + foreach (array_keys($this->testInertiaComponents) as $testRel) { + if (! is_file($root.$testRel)) { + unset($this->testInertiaComponents[$testRel]); + } + } + foreach (array_keys($this->testTables) as $testRel) { if (! is_file($root.$testRel)) { unset($this->testTables[$testRel]); @@ -624,6 +877,53 @@ final class Graph } } + if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) { + foreach ($data['test_inertia_components'] as $testRel => $components) { + if (! is_string($testRel)) { + continue; + } + if (! is_array($components)) { + continue; + } + $names = []; + + foreach ($components as $component) { + if (is_string($component) && $component !== '') { + $names[] = $component; + } + } + + if ($names !== []) { + $graph->testInertiaComponents[$testRel] = $names; + } + } + } + + if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) { + foreach ($data['js_file_to_components'] as $path => $components) { + if (! is_string($path)) { + continue; + } + if ($path === '') { + continue; + } + if (! is_array($components)) { + continue; + } + $names = []; + + foreach ($components as $component) { + if (is_string($component) && $component !== '') { + $names[] = $component; + } + } + + if ($names !== []) { + $graph->jsFileToComponents[$path] = $names; + } + } + } + return $graph; } @@ -642,6 +942,8 @@ final class Graph 'edges' => $this->edges, 'baselines' => $this->baselines, 'test_tables' => $this->testTables, + 'test_inertia_components' => $this->testInertiaComponents, + 'js_file_to_components' => $this->jsFileToComponents, ]; $json = json_encode($payload, JSON_UNESCAPED_SLASHES); diff --git a/src/Plugins/Tia/InertiaEdges.php b/src/Plugins/Tia/InertiaEdges.php new file mode 100644 index 00000000..fdf14c75 --- /dev/null +++ b/src/Plugins/Tia/InertiaEdges.php @@ -0,0 +1,170 @@ +` (full HTML shape). Both carry + * the component name in a structured payload we can parse cheaply. + * + * Same dep-free handshake as `BladeEdges` / `TableTracker`: string + * class lookup + method-capability probes so Pest's `require` stays + * Laravel-free. + * + * @internal + */ +final class InertiaEdges +{ + private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; + + private const string REQUEST_HANDLED_EVENT = '\\Illuminate\\Foundation\\Http\\Events\\RequestHandled'; + + /** + * App-scoped marker that makes `arm()` idempotent across per-test + * `setUp()` calls. Laravel reuses the same app across tests in + * most configurations — without this guard we'd stack one + * listener per test. + */ + private const string MARKER = 'pest.tia.inertia-edges-armed'; + + public static function arm(Recorder $recorder): void + { + if (! $recorder->isActive()) { + return; + } + + $containerClass = self::CONTAINER_CLASS; + + if (! class_exists($containerClass)) { + return; + } + + /** @var object $app */ + $app = $containerClass::getInstance(); + + if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) { + return; + } + + if ($app->bound(self::MARKER)) { + return; + } + + if (! $app->bound('events')) { + return; + } + + $app->instance(self::MARKER, true); + + /** @var object $events */ + $events = $app->make('events'); + + if (! method_exists($events, 'listen')) { + return; + } + + $events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void { + if (! property_exists($event, 'response')) { + return; + } + + /** @var mixed $response */ + $response = $event->response; + + if (! is_object($response)) { + return; + } + + $component = self::extractComponent($response); + + if ($component !== null) { + $recorder->linkInertiaComponent($component); + } + }); + } + + /** + * Pulls the Inertia component name out of a Laravel response, + * handling both XHR (`X-Inertia` + JSON body) and full HTML + * (`
`) shapes. Returns null for any + * non-Inertia response so the caller can ignore it cheaply. + */ + private static function extractComponent(object $response): ?string + { + // XHR path: Inertia sets an `X-Inertia: true` header and the + // body is JSON with a `component` key. + if (property_exists($response, 'headers') && is_object($response->headers)) { + $headers = $response->headers; + + if (method_exists($headers, 'has') && $headers->has('X-Inertia')) { + $content = self::readContent($response); + + if ($content !== null) { + /** @var mixed $decoded */ + $decoded = json_decode($content, true); + + if (is_array($decoded) + && isset($decoded['component']) + && is_string($decoded['component']) + && $decoded['component'] !== '') { + return $decoded['component']; + } + } + } + } + + // 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. + $content = self::readContent($response); + + if ($content === null || ! str_contains($content, 'data-page=')) { + return null; + } + + if (preg_match('/\sdata-page="([^"]+)"/', $content, $match) !== 1) { + return null; + } + + $decoded = json_decode(html_entity_decode($match[1]), true); + + if (is_array($decoded) + && isset($decoded['component']) + && is_string($decoded['component']) + && $decoded['component'] !== '') { + return $decoded['component']; + } + + return null; + } + + private static function readContent(object $response): ?string + { + if (! method_exists($response, 'getContent')) { + return null; + } + + /** @var mixed $content */ + $content = $response->getContent(); + + return is_string($content) ? $content : null; + } +} diff --git a/src/Plugins/Tia/JsImportParser.php b/src/Plugins/Tia/JsImportParser.php new file mode 100644 index 00000000..4b655641 --- /dev/null +++ b/src/Plugins/Tia/JsImportParser.php @@ -0,0 +1,270 @@ +` blocks parsed whole; imports inside + * `