Skip to content
LaravelBlog

livewire/livewire: Stabilize loading delay browser test

LaravelBlogBot
LaravelBlogBot

Contributed by calebporzio

Part of the livewire v4.4.7 release

Pull request #10726 is a test-suite-only stabilization of the legacy browser test for Livewire's wire:loading delay modifiers, ported from #10725 to the 4.x branch. The production loading code is completely untouched — the PR exists because CI kept failing on timing jitter, not because anything regressed. If you're upgrading from v3.8.10, there is nothing to change in your application, but the change restores previously skipped coverage for the 1000ms longest delay.

What changed

Two files under legacy_tests/Browser/Loading/ were modified.

First, the test fixture sleeps inside hydrate() to keep the request pending long enough for delayed loaders to appear. That sleep was raised from 1100ms to 1500ms:

public function hydrate()
{
    // Sleep long enough to observe the 1000ms longest delay with room for browser scheduling.
    usleep(1000 * 1500);
}

With the old 1100ms sleep, the request could finish barely 100ms after the longest threshold fired — not enough headroom on a busy CI runner. That tight margin is why the final assertion had been commented out with @todo: this is flaky.... This PR restores that assertion.

Second, the test itself was restructured from "check each threshold exactly one millisecond after it fires" to "check groups of thresholds inside wider timing windows." Each waitForLivewire() block clicks the load button once, then asserts a whole band of loaders at a single point in time.

Why the old test flaked

The old test verified each delay with a one-millisecond grace period: pause(51) for the 50ms shortest loader, pause(101) for the 100ms shorter loader, and so on up to pause(501) for longer. One millisecond sits well inside normal browser and CI jitter. Timer clamping, event-loop contention, and round-trip latency across the workflow's different PHP/Laravel shards all shift when an assertion actually runs relative to the timer — so on some runs a "51ms" check effectively executed before the timer had fired.

That explains the tell-tale symptoms: different selectors failing on different shards, failures that persisted even through workflow retries, and flakiness concentrated at the extreme thresholds. It was a harness timing problem, not a defect in the loading feature.

The new windows give each assertion a much wider safety band:

  • Immediately — the undelayed @delay-none loader is visible
  • 250ms — the 50/100/150/200ms loaders visible; the 300ms+ loaders not
  • 400ms — the 300ms long loader visible; 500ms and 1000ms not
  • 750ms — the 500ms longer loader visible; 1000ms not
  • 1250ms — the 1000ms longest loader visible (restored assertion)

What this means for the loading-delay feature

Nothing changed at runtime. The modifiers and their thresholds are exactly as before:

Modifier Delay
(default) 200ms
.shortest 50ms
.shorter 100ms
.short 150ms
.long 300ms
.longer 500ms
.longest 1000ms

Your existing markup keeps working identically:

<div>
    <button wire:click="export">Export</button>

    {{-- Avoids flashing on fast requests --}}
    <span wire:loading.delay.shortest>Working…</span>

    {{-- Only appears for genuinely slow requests --}}
    <span wire:loading.delay.longest>Still exporting — hang tight…</span>
</div>

The coverage is also still browser-observable: the test continues to drive a real browser and assert real visibility states, rather than weakening into a unit-level check of the timing logic.

How it was verified

The fix was validated the way a timing flake should be: the failures were first reproduced across multiple 4.x primary runs on different shards, then the focused 4.x LegacyBrowser test was run green (1 test, 16 assertions), and an equivalent patch on main passed three consecutive focused browser runs. git diff --check is clean, so there's no stray whitespace to worry about. The "same patch passes on main" step is a nice touch — it confirms the stabilization strategy is portable between branches, not just a local patch to one test.

Upgrade impact

For anyone upgrading from v3.8.10, the impact is zero. The diff touches only files under legacy_tests/, so there are no runtime changes, no public API changes, and no configuration to adjust. Loading states with delay modifiers behave exactly as they did before this PR.

The only group that needs to act is anyone maintaining a fork of Livewire or running copies of these legacy tests in their own pipeline: pull in both the restructured test and the 1500ms fixture sleep, otherwise your CI will keep hitting the same one-millisecond race.

Takeaways

  • Test-only change: production wire:loading delay behavior is unchanged, so no action is needed in your app.
  • The flakiness came from asserting each delay threshold with only 1ms of grace; timing jitter across PHP/Laravel CI shards made different selectors miss on different runs.
  • Assertions are now grouped into wider timing windows (0ms, 250ms, 400ms, 750ms, 1250ms), keeping real browser coverage with far more slack.
  • The fixture's hydrate() sleep grew from 1100ms to 1500ms, which restored the previously skipped 1000ms longest assertion.
  • Verified green on focused 4.x runs and across three consecutive runs of the equivalent main patch.

Sources

More from this release

Related Articles