Skip to content

Learning

Ghostwire normally synthesizes a skeleton by walking the current DOM the instant a Livewire request starts — so a lazy-loaded component’s very first paint, on a visitor’s first-ever visit, has nothing to walk yet. Learning closes that gap: it remembers each component’s Bone Tree locally, in the browser, and repaints it the next time that component is about to lazy-load, before any real content exists. This capture runs on every eligible commit the moment it starts, independent of the visible skeleton’s own anti-flicker delay (timing.delay) — a commit that resolves instantly is captured exactly the same as one slow enough to actually show its own skeleton.

Learning is off by default. Enable it with an environment variable, or the equivalent config key:

Terminal window
GHOSTWIRE_LEARNING=true
config/ghostwire.php
'learning' => [
'enabled' => true,
],

The flag only enables skeleton capture and produces no visible effect on its own — to see a learned skeleton painted, pair it with #[Ghost(lazy: true)] on your component (and Livewire’s #[Lazy] if you want the skeleton to appear on the initial load).

Whatever the flag says, Ghostwire also refuses to collect anything while app()->isProduction() is true — both checks run server-side, per request, so a client can never turn learning on for itself. Learning also only runs against the local store (learning.store in config/ghostwire.php); any other value refuses it outright.

Learned trees live in localStorage, under the key ghostwire.learned.v1 — one tree per component per breakpoint band. Each entry is the component’s own name, a timestamp used for eviction, the host’s width/height, and a flat list of bones (type, x, y, width, height): geometry and a bone type from a fixed enum, never text, never DOM content, nothing from the page’s own data.

Nothing here is ever sent anywhere — no network request exists on this path. The only way learned data leaves the browser is the user-initiated download described under Exporting, below. You can clear a browser’s learned data at any time with Ghostwire.clearLearned().

The store is re-validated on every read. Anything that doesn’t match the expected shape — a corrupted value, an entry that fails validation — is discarded silently, and the affected component simply learns again on its next visit. Storage is capped at a fixed 256 KB budget, with the least-recently-written entries evicted first once it’s full.

Learned trees are a cache, keyed to a schema version. A Ghostwire upgrade that bumps the version discards every previously learned tree — there’s no migration path, because migrating a derived cache would be over-engineering: each entry is simply re-learned on the next visit, at the cost of one re-synthesis, and there’s nothing a developer needs to do. The one part that doesn’t take care of itself is the old value left in localStorage: it’s removed the next time it’s read, rather than orphaned there forever.

use Ghostwire\Attributes\Ghost;
#[Ghost(lazy: true)]
class OrdersTable extends Component
{
// ...
}

For a component using Livewire’s own lazy loading, lazy: true tells Ghostwire to paint the learned skeleton into the placeholder Livewire renders, before the component’s real content ever loads.

This never applies if the component already has a placeholder of its own: a placeholder() method, a Livewire 4 @placeholder block, or a global default placeholder your app has configured (livewire.component_placeholder on Livewire 4, livewire.lazy_placeholder on Livewire 3). Any of those wins outright — Ghostwire only ever fills a placeholder that would otherwise be blank.

Pairing Livewire’s own lazy loading with Ghost’s — no placeholder() method, so Ghostwire’s learned skeleton fills the gap once one exists for this component and breakpoint:

use Livewire\Attributes\Lazy;
use Ghostwire\Attributes\Ghost;
#[Lazy]
#[Ghost(lazy: true)]
class OrdersTable extends Component
{
// ...
}

Turn what learning collected into a real, static Blade placeholder — no headless browser involved at any point:

// In the browser, on a page where learning has run:
Ghostwire.exportLearned() // downloads ghostwire-learned.json
Terminal window
php artisan ghost:export --component=orders-table --breakpoint=lg \
--from=~/Downloads/ghostwire-learned.json

ghost:export re-validates the downloaded file independently of anything the browser already checked — by the time it reaches this command, it’s a file that passed through a developer’s download directory. It writes one static Blade partial per --component/--breakpoint pair under resources/views (--output to choose where; --force to overwrite an existing file at that destination) and prints the view name to use:

public function placeholder()
{
return view('livewire.orders-table-placeholder');
}

A learned tree is keyed to one of six width bands, matching the viewport’s width at the moment it was learned:

BandMinimum width
xs0
sm640
md768
lg1024
xl1280
2xl1536

A component keeps one learned tree per band — resize the window, or view it on a different device, and a different tree applies once one has been learned at that width.

Learning follows the same hardening as the rest of Ghostwire’s transport — see Security for the full picture. Specific to this feature:

  • A component’s name is validated against [a-z0-9.-] (1–64 characters) before it’s ever used to look up or store a tree, both in the browser and by ghost:export.
  • ghost:export’s destination is resolved canonically and refused outright if it would land outside resources/views — including through a symlinked parent directory.
  • The destination itself is refused if it’s a symlink (live or dangling) or an existing directory, regardless of --force; overwriting any other existing file at that destination still requires --force.
  • The Blade partial ghost:export writes is fully static: every learned number is clamped and printed as a plain pixel value, and the bone type comes from a closed enum. No learned value is ever interpolated as markup, and nothing read back from the store can reach eval or innerHTML.