From 5561784314ff8daea8b52a09c879d7ed1fdcc55a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 27 Jun 2026 00:29:48 -0400 Subject: [PATCH] Refine runtime form control preservation --- .../src/ArtifactCompiler/ArtifactCompiler.php | 75 +++++++++++++++++++ .../RuntimeDependencyParityReport.php | 58 +++++++++++++- php-transformer/tests/contract/run.php | 23 +++++- ...runtime-target-container-preservation.json | 10 ++- 4 files changed, 159 insertions(+), 7 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index e9fd898f..f71a9805 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -281,8 +281,13 @@ private function linkedStylesheetCss(string $html, string $sourcePath, array $fi private function runtimeDomSelectors(string $html, string $sourcePath, array $files): array { $selectors = array(); + $controlSelectors = $this->formControlSelectors($html); foreach ( $this->documentScriptContents($html, $sourcePath, $files) as $script ) { + $runtimeControlSelectors = $this->scriptControlRuntimeSelectors($script); foreach ( $this->scriptDomSelectors($script) as $selector ) { + if ( isset($controlSelectors[$selector]) && ! isset($runtimeControlSelectors[$selector]) ) { + continue; + } $selectors[$selector] = true; } } @@ -385,6 +390,40 @@ private function canvasSelectors(string $html): array return $selectors; } + /** + * @return array + */ + private function formControlSelectors(string $html): array + { + $selectors = array(); + $document = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $loaded = $document->loadHTML('' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + if ( ! $loaded ) { + return array(); + } + + foreach ( $document->getElementsByTagName('*') as $element ) { + if ( ! $element instanceof DOMElement || ! in_array(strtolower($element->tagName), array('button', 'input', 'select', 'textarea'), true) ) { + continue; + } + + $id = trim($element->hasAttribute('id') ? $element->getAttribute('id') : ''); + if ( '' !== $id ) { + $selectors['#' . $id] = true; + } + foreach ( preg_split('/\s+/', trim($element->hasAttribute('class') ? $element->getAttribute('class') : '')) ?: array() as $class ) { + if ( '' !== $class ) { + $selectors['.' . $class] = true; + } + } + } + + return $selectors; + } + /** * @param array> $files * @return array @@ -442,6 +481,42 @@ private function scriptDomSelectors(string $script): array return array_keys($selectors); } + /** + * @return array + */ + private function scriptControlRuntimeSelectors(string $script): array + { + $selectors = array(); + $runtimeUsePattern = '\.\s*(?:addEventListener|value|checked|selectedIndex|selectedOptions|options|files|validity|setCustomValidity|focus|select|click|dispatchEvent)\b'; + + if ( preg_match_all('/document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*(?:\.\s*[^;\n]*)?' . $runtimeUsePattern . '/', $script, $matches) ) { + foreach ( $matches[2] as $id ) { + $selectors['#' . (string) $id] = true; + } + } + if ( preg_match_all('/document\s*\.\s*querySelector(?:All)?\s*\(\s*(["\'])([#.][A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*(?:\.\s*[^;\n]*)?' . $runtimeUsePattern . '/', $script, $matches) ) { + foreach ( $matches[2] as $selector ) { + $selectors[(string) $selector] = true; + } + } + if ( preg_match_all('/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { + foreach ( $assignments as $assignment ) { + if ( preg_match('/\b' . preg_quote((string) $assignment[1], '/') . '\s*' . $runtimeUsePattern . '/', $script) ) { + $selectors['#' . (string) $assignment[3]] = true; + } + } + } + if ( preg_match_all('/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*document\s*\.\s*querySelector(?:All)?\s*\(\s*(["\'])([#.][A-Za-z][A-Za-z0-9_-]*)\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { + foreach ( $assignments as $assignment ) { + if ( preg_match('/\b' . preg_quote((string) $assignment[1], '/') . '\s*' . $runtimeUsePattern . '/', $script) ) { + $selectors[(string) $assignment[3]] = true; + } + } + } + + return $selectors; + } + private function htmlAttribute(string $tag, string $name): string { if ( preg_match('/\s' . preg_quote($name, '/') . '\s*=\s*(["\'])(.*?)\1/is', $tag, $match) ) { diff --git a/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php b/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php index d0f39e78..758b1639 100644 --- a/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php +++ b/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php @@ -58,6 +58,10 @@ public function fromArtifact(array $files, string $sourceHtml, string $generated continue; } + if ( $this->isFormControlTarget($target) && true !== ( $dependency['control_runtime'] ?? false ) ) { + continue; + } + $severity = 'telemetry' === $scriptKind ? 'info' : 'warning'; $repairBucket = $canvasApi ? 'runtime_canvas_target_preservation' : 'runtime_dom_target_preservation'; $findings[] = array_filter(array( @@ -169,6 +173,7 @@ private function scriptDependencies(string $script): array $dependencies = array(); $eventsBySelector = $this->eventsBySelector($script); $canvasSelectors = $this->scriptCanvasSelectors($script); + $controlRuntimeSelectors = $this->scriptControlRuntimeSelectors($script); if ( preg_match_all('/document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\1\s*\)/', $script, $matches) ) { foreach ( $matches[2] as $id ) { @@ -178,6 +183,7 @@ private function scriptDependencies(string $script): array 'selector' => $selector, 'events' => $eventsBySelector[$selector] ?? array(), 'canvas_api' => isset($canvasSelectors[$selector]), + 'control_runtime' => isset($controlRuntimeSelectors[$selector]), ); } } @@ -190,6 +196,7 @@ private function scriptDependencies(string $script): array 'selector' => $selector, 'events' => $eventsBySelector[$selector] ?? array(), 'canvas_api' => isset($canvasSelectors[$selector]), + 'control_runtime' => isset($controlRuntimeSelectors[$selector]), ); } } @@ -202,6 +209,7 @@ private function scriptDependencies(string $script): array 'selector' => $selector, 'events' => $eventsBySelector[$selector] ?? array(), 'canvas_api' => isset($canvasSelectors[$selector]), + 'control_runtime' => isset($controlRuntimeSelectors[$selector]), ); } } @@ -214,6 +222,7 @@ private function scriptDependencies(string $script): array 'selector' => $selector, 'events' => $eventsBySelector[$selector] ?? array(), 'canvas_api' => isset($canvasSelectors[$selector]), + 'control_runtime' => isset($controlRuntimeSelectors[$selector]), ); } } @@ -221,6 +230,42 @@ private function scriptDependencies(string $script): array return $this->dedupeDependencies($dependencies); } + /** + * @return array + */ + private function scriptControlRuntimeSelectors(string $script): array + { + $selectors = array(); + $runtimeUsePattern = '\.\s*(?:addEventListener|value|checked|selectedIndex|selectedOptions|options|files|validity|setCustomValidity|focus|select|click|dispatchEvent)\b'; + + if ( preg_match_all('/document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*(?:\.\s*[^;\n]*)?' . $runtimeUsePattern . '/', $script, $matches) ) { + foreach ( $matches[2] as $id ) { + $selectors['#' . (string) $id] = true; + } + } + if ( preg_match_all('/document\s*\.\s*querySelector(?:All)?\s*\(\s*(["\'])([#.][A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*(?:\.\s*[^;\n]*)?' . $runtimeUsePattern . '/', $script, $matches) ) { + foreach ( $matches[2] as $selector ) { + $selectors[(string) $selector] = true; + } + } + if ( preg_match_all('/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { + foreach ( $assignments as $assignment ) { + if ( preg_match('/\b' . preg_quote((string) $assignment[1], '/') . '\s*' . $runtimeUsePattern . '/', $script) ) { + $selectors['#' . (string) $assignment[3]] = true; + } + } + } + if ( preg_match_all('/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*document\s*\.\s*querySelector(?:All)?\s*\(\s*(["\'])([#.][A-Za-z][A-Za-z0-9_-]*)\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { + foreach ( $assignments as $assignment ) { + if ( preg_match('/\b' . preg_quote((string) $assignment[1], '/') . '\s*' . $runtimeUsePattern . '/', $script) ) { + $selectors[(string) $assignment[3]] = true; + } + } + } + + return $selectors; + } + /** * @return array */ @@ -260,6 +305,14 @@ private function scriptCanvasSelectors(string $script): array return $selectors; } + /** + * @param array $target + */ + private function isFormControlTarget(array $target): bool + { + return in_array((string) ($target['tag'] ?? ''), array('button', 'input', 'select', 'textarea'), true); + } + /** * @return array> */ @@ -330,8 +383,8 @@ private function scriptKind(string $path, string $script): string } /** - * @param array, canvas_api: bool}> $dependencies - * @return array, canvas_api: bool}> + * @param array, canvas_api: bool, control_runtime?: bool}> $dependencies + * @return array, canvas_api: bool, control_runtime?: bool}> */ private function dedupeDependencies(array $dependencies): array { @@ -341,6 +394,7 @@ private function dedupeDependencies(array $dependencies): array if ( isset($deduped[$selector]) ) { $deduped[$selector]['events'] = array_values(array_unique(array_merge($deduped[$selector]['events'], $dependency['events']))); $deduped[$selector]['canvas_api'] = $deduped[$selector]['canvas_api'] || $dependency['canvas_api']; + $deduped[$selector]['control_runtime'] = ( $deduped[$selector]['control_runtime'] ?? false ) || ( $dependency['control_runtime'] ?? false ); continue; } $deduped[$selector] = $dependency; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 4b620334..edf96f4a 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -317,6 +317,27 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert('.js-sort-select' === ($standaloneControls['source_reports']['runtime_islands'][0]['selector'] ?? ''), 'runtime-targeted standalone control reports selector metadata'); $assert('select' === ($standaloneControls['source_reports']['runtime_islands'][0]['control']['tag'] ?? ''), 'runtime-targeted standalone control reports control metadata'); +$artifactControlSelectors = ( new ArtifactCompiler() )->compile( + array( + 'entrypoint' => 'index.html', + 'files' => array( + 'index.html' => '
', + 'js/app.js' => 'document.getElementById("newsletter-email"); document.querySelector(".sort-select"); const liveFilter = document.getElementById("live-filter"); liveFilter.addEventListener("input", function () { window.__changed = true; });', + ), + ) +)->toArray(); +$artifactControlMarkup = (string) ($artifactControlSelectors['serialized_blocks'] ?? ''); +$assert(! str_contains($artifactControlMarkup, 'transform( '

Reserve now

' )->toArray(); @@ -909,7 +930,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $runtimeTargetDependencies[$dependency['selector'] ?? ''] = $dependency; } $assert('pass' === ($runtimeTargetContainerReport['status'] ?? ''), 'runtime dependency parity passes generic preserved JS target containers'); -foreach ( array( '.reveal', '.nav-toggle', '.menu-shell', '.primary-nav', '.mobile-nav-overlay', '.mobile-nav', '.faq-item', '.filter-btn', '.button-shell', '.filter-bar', '.filter-chips', '#note-search', '.search-input', '.js-sort-select', '.js-filter-check', '#contact-form', '#form-success' ) as $selector ) { +foreach ( array( '.reveal', '.nav-toggle', '.menu-shell', '.primary-nav', '.mobile-nav-overlay', '.mobile-nav', '.faq-item', '.filter-btn', '.button-shell', '.filter-bar', '.filter-chips', '#contact-form', '#form-success' ) as $selector ) { $assert(true === ($runtimeTargetDependencies[$selector]['generated_present'] ?? null), 'runtime dependency parity records preserved target ' . $selector); } $assert(str_contains((string) ($runtimeTargetContainerSite['serialized_blocks'] ?? ''), 'nav-toggle'), 'artifact block markup preserves runtime-targeted menu toggle class'); diff --git a/php-transformer/tests/fixtures/parity/artifact-runtime-target-container-preservation.json b/php-transformer/tests/fixtures/parity/artifact-runtime-target-container-preservation.json index f0507f5a..0c0a1576 100644 --- a/php-transformer/tests/fixtures/parity/artifact-runtime-target-container-preservation.json +++ b/php-transformer/tests/fixtures/parity/artifact-runtime-target-container-preservation.json @@ -44,10 +44,12 @@ { "path": "serialized_blocks", "assert": "contains", "value": "filter-bar" }, { "path": "serialized_blocks", "assert": "contains", "value": "filter-chips" }, { "path": "serialized_blocks", "assert": "contains", "value": "card" }, - { "path": "serialized_blocks", "assert": "contains", "value": "note-search" }, - { "path": "serialized_blocks", "assert": "contains", "value": "search-input" }, - { "path": "serialized_blocks", "assert": "contains", "value": "js-sort-select" }, - { "path": "serialized_blocks", "assert": "contains", "value": "js-filter-check" }, + { "path": "serialized_blocks", "assert": "contains", "value": "Search notes" }, + { "path": "serialized_blocks", "assert": "contains", "value": "Newest" }, + { "path": "serialized_blocks", "assert": "contains", "value": "available" }, + { "path": "serialized_blocks", "assert": "not_contains", "value": "note-search" }, + { "path": "serialized_blocks", "assert": "not_contains", "value": "js-sort-select" }, + { "path": "serialized_blocks", "assert": "not_contains", "value": "js-filter-check" }, { "path": "serialized_blocks", "assert": "contains", "value": "contact-form" }, { "path": "serialized_blocks", "assert": "contains", "value": "form-success" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "drawer-nav" }