Keep the search view closed when its focus comes back late
tests / feature (8.4) (push) Successful in 1m49s
tests / feature (8.5) (push) Successful in 1m51s
tests / browser (chrome, chromium) (push) Successful in 7m34s
tests / browser (firefox, firefox) (push) Failing after 14m19s
tests / browser (safari, webkit) (push) Failing after 13m16s

On a slow machine Escape could close the full-screen search and have it
open again for good. A close hands focus back to the input — Escape does,
and so does the view's focus trap as it lets go — and `focused()` told
that return from someone coming to search by a 250ms wall-clock window.
Both returns run on frames, and a runner painting a few frames a second
took longer than that, so the returning focus opened the view again. A
`returning` flag now covers the close until the hand-back has actually
run, on the same frame, and the constant is gone.

`hold()` times the full-screen layout from the exit's own duration token,
the one Alpine's x-transition holds `display` for, instead of waiting a
frame or two for the exit's transitions to appear: `getAnimations()` is
empty both before an engine creates them and after they end, and a loaded
engine can leave a second between frames.

The browser tests were racing the same slow machine, reproduced in a
Linux container like the runner, with its Playwright Firefox and
WebKitGTK and two cores kept busy:

- The browser plugin retries every script and action with a one-second
  attempt until the budget runs out. An in-page sleep longer than that
  only ever passed on the last attempt, which is where the 47-50s tests
  came from, and a script that clicks was run again against a page that
  had moved on. `onceInPage()` runs such a script once however often it
  is retried; the long sleeps are plain retried conditions now.
- Under load WebKitGTK paints no frame while a tight setTimeout loop
  runs, so a sample loop saw the start value and then nothing.
  `caughtMidExit()` samples on animation frames, for 2.5s.
- "No animations running" is also true before an opening transition
  exists, so a close could be sampled from a scrim at 4% opacity.
  `settled()` waits two frames before it asks.

The workflow no longer uploads failure screenshots: Gitea's artifact
service timed out on every attempt, two minutes per red run, and the job
logs are readable without it.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit on
macOS, and on Firefox and WebKitGTK in the Linux container under load,
twice each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-18 09:49:17 +02:00
co-authored by Claude Opus 5
parent 8da8b1fa20
commit e7885ce1eb
8 changed files with 270 additions and 188 deletions
+63 -64
View File
@@ -442,14 +442,18 @@ it('cycles a bottom sheet\'s preset heights from its handle, announcing each, an
->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'")
// Settled, not still sliding in: a grip pressed while the entry is still under way would
// read as unstable on a loaded runner, the way opening it does elsewhere in this file.
->assertScript("{$sheet}.getAnimations({ subtree: true }).length === 0");
->assertScript(settled($sheet, subtree: true));
$gripSelector = '#containment [data-md-bottom-sheet-panel][data-md-preset] [data-md-bottom-sheet-grip]';
$page->click($gripSelector);
$page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '90dvh'")
->assertScript("{$announce}.textContent.trim() === 'Height 3 of 3'");
->assertScript("{$announce}.textContent.trim() === 'Height 3 of 3'")
// The stop is written before the sheet has grown to it, so wait for the growth as well as
// for the entry above: the grip travels with the sheet's edge, and a press that lands
// while it is moving never reads as stable.
->assertScript(settled($sheet, subtree: true));
// From the last stop, activating the handle closes the sheet, as a handle with no stops does.
$page->click($gripSelector);
@@ -519,35 +523,32 @@ it('gives a closing standard side sheet\'s room back to the content beside it as
// The example starts open, and stands open from the first frame Alpine draws: full 400px, fully
// shown, nothing on it animating.
$page = containment()->resize(1000, 800)
->assertScript("{$root}.hasAttribute('data-md-open') && Math.round({$root}.getBoundingClientRect().width) === 400 && getComputedStyle({$sheet}).opacity === '1' && {$root}.getAnimations({ subtree: true }).length === 0");
->assertScript("{$root}.hasAttribute('data-md-open') && Math.round({$root}.getBoundingClientRect().width) === 400 && getComputedStyle({$sheet}).opacity === '1'")
->assertScript(settled($root, subtree: true))
// Only once the view has settled, two frames after Alpine starts, which WebKit can still
// be waiting for here: until then the sheet has no transitions, on purpose, and closes at
// once.
->assertScript("{$root}.hasAttribute('data-md-drawer-settled')");
// Closed and sampled in one round trip: the sheet's root caught part-way between its width
// and none, still in the layout, while the column beside it has grown part of the way — not
// the whole sheet and its gap handed back in one jump at the end. Only once the view has
// settled, two frames after Alpine starts, which WebKit can still be waiting for here: until
// then the sheet has no transitions, on purpose, and closes at once.
$midExit = $page->script(<<<JS
(async () => {
const root = {$root}
const column = {$column}
// the whole sheet and its gap handed back in one jump at the end.
$midExit = $page->script(caughtMidExit(
<<<JS
const root = {$root}
const column = {$column}
const open = { root: root.getBoundingClientRect().width, column: column.getBoundingClientRect().width, row: root.parentElement.getBoundingClientRect().width }
{$toggle}.click()
JS,
<<<'JS'
(() => {
const width = root.getBoundingClientRect().width
const columnWidth = column.getBoundingClientRect().width
for (let i = 0; i < 100 && ! root.hasAttribute('data-md-drawer-settled'); i++) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
const open = { root: root.getBoundingClientRect().width, column: column.getBoundingClientRect().width, row: root.parentElement.getBoundingClientRect().width }
{$toggle}.click()
for (let i = 0; i < 80; i++) {
const width = root.getBoundingClientRect().width
const columnWidth = column.getBoundingClientRect().width
if (! root.hasAttribute('data-md-drawer-collapsed') && getComputedStyle(root).display !== 'none' && width > 1 && width < open.root - 1 && columnWidth > open.column + 1 && columnWidth < open.row - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
return ! root.hasAttribute('data-md-drawer-collapsed') && getComputedStyle(root).display !== 'none' && width > 1 && width < open.root - 1 && columnWidth > open.column + 1 && columnWidth < open.row - 1
})()
JS);
JS,
));
expect($midExit)->toBeTrue();
@@ -1001,28 +1002,24 @@ it('fades the sheet\'s scrim out on close, rather than making it vanish', functi
$page = containment()
->click($trigger)
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("getComputedStyle({$scrim}).opacity === '1'");
->assertScript("getComputedStyle({$scrim}).opacity === '1'")
// Settled, not still fading in: an engine reads a transition's end value until its next
// refresh tick, so an opacity of 1 alone does not say the entry has run — and a close
// sampled from there has nothing left to fade.
->assertScript(settled($scrim));
slowMotion($page);
// Triggering the close and sampling for a mid-fade opacity in the same round trip: a round
// trip apiece for a separate `script` and `assertScript` already costs real time, easily as
// much as the 200ms fade itself, so a click, then a later separate read, can just as easily
// land before the fade starts or after it ends. This samples every few ms, in the page itself,
// for whether it was ever caught strictly between fully shown and fully hidden, rather than
// simply gone at once.
$midFade = $page->script(<<<JS
(async () => {
{$scrim}.click()
for (let i = 0; i < 60; i++) {
const opacity = parseFloat(getComputedStyle({$scrim}).opacity)
if (opacity > 0.02 && opacity < 0.98) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS);
// much as the fade itself, so a click, then a later separate read, can just as easily land
// before the fade starts or after it ends. This samples once a frame, in the page itself, for
// whether it was ever caught strictly between fully shown and fully hidden, rather than simply
// gone at once (caughtMidExit() in tests/Pest.php).
$midFade = $page->script(caughtMidExit(
"{$scrim}.click()",
"(() => { const opacity = parseFloat(getComputedStyle({$scrim}).opacity); return opacity > 0.02 && opacity < 0.98 })()",
));
expect($midFade)->toBeTrue();
@@ -1037,31 +1034,32 @@ it('slides the sheet out on close, rather than making it vanish', function (stri
$page = containment()
->click($trigger)
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("Math.abs({$offset}) < 0.5");
->assertScript("Math.abs({$offset}) < 0.5")
// Settled, not still sliding in: see the scrim's fade above.
->assertScript(settled($sheet));
slowMotion($page);
// Sampled in the page, in the same round trip as the close, as the scrim's fade is above: the
// sheet must be caught still displayed and part of the way to its closed offset (one sheet's
// own size past its edge), not gone at once — which is what Firefox showed while the exit
// leaned on `allow-discrete` holding `display`. 400 iterations (2000ms): the sheet closes on
// the emphasized-accelerate easing, which stays close to its start for a good part of its run,
// so slowMotion()'s stretched exit needs longer than a scrim's fade (a spring, front-loaded)
// before the offset has moved past a pixel.
$midSlide = $page->script(<<<JS
(async () => {
const size = {$sheet}.getBoundingClientRect().{$dimension}
{$scrim}.click()
// leaned on `allow-discrete` holding `display`. The sheet closes on the emphasized-accelerate
// easing, which stays close to its start for a good part of its run, so it takes a few of the
// stretched exit's frames before the offset has moved past a pixel.
$midSlide = $page->script(caughtMidExit(
<<<JS
const size = {$sheet}.getBoundingClientRect().{$dimension}
{$scrim}.click()
JS,
<<<JS
(() => {
const style = getComputedStyle({$sheet})
const offset = {$sample}
for (let i = 0; i < 400; i++) {
const style = getComputedStyle({$sheet})
const offset = {$sample}
if (style.display !== 'none' && offset > 1 && offset < size - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
return style.display !== 'none' && offset > 1 && offset < size - 1
})()
JS);
JS,
));
expect($midSlide)->toBeTrue();
@@ -1498,7 +1496,8 @@ it('eases a collapse bound to Alpine open and shut, keeping the binding in step'
it('turns a collapse round from the height it has reached, ending as the last press asked', function () {
$page = collapseMotionProbe('1500ms');
$result = $page->script(<<<'JS'
// Two seconds in the page, so it is run once: see onceInPage() in tests/Pest.php.
$result = $page->script(onceInPage(<<<'JS'
(async () => {
const details = document.querySelector('#plain')
const summary = details.querySelector('summary')
@@ -1516,7 +1515,7 @@ it('turns a collapse round from the height it has reached, ending as the last pr
return { closed, midway, turned, end: details.getBoundingClientRect().height, open: details.open, style: details.getAttribute('style') }
})()
JS);
JS));
expect($result['midway'])->toBeGreaterThan($result['closed'] + 2)
// Turned round from where it was, not from either end.
@@ -1531,7 +1530,7 @@ it('closes the open member of a name group on the same spring as the one opening
$first = "document.querySelector('#first')";
$result = $page->script(<<<'JS'
$result = $page->script(onceInPage(<<<'JS'
(async () => {
const first = document.querySelector('#first')
const second = document.querySelector('#second')
@@ -1550,7 +1549,7 @@ it('closes the open member of a name group on the same spring as the one opening
return { stillOpen, caught, first: first.open, second: second.open, name: first.getAttribute('name') }
})()
JS);
JS));
expect($result)->toBe(['stillOpen' => true, 'caught' => true, 'first' => false, 'second' => true, 'name' => 'faq']);