# Livewire + Alpine.js Live Progress Bar

A pattern for showing a live-updating progress bar for long-running Laravel queue jobs, without blocking Livewire's processing state.

---

## The Problem

Long-running jobs (Shopify inventory sync, imports, etc.) need a live progress indicator. The naive approach of using `wire:poll` to refresh the Livewire component every 2 seconds causes:

- All heavy `#[Computed]` properties re-evaluate on every poll tick
- Livewire stays in "loading" state continuously
- Filament action modal buttons become unclickable (blocked by loading state)
- Page feels sluggish

---

## The Solution: Alpine.js Polling a Dedicated JSON Endpoint

Keep the progress bar completely outside Livewire's processing cycle. Alpine.js polls a lightweight route using `fetch()` and updates the DOM directly.

### Architecture

```
Queue Job → Cache::put(progress) → JSON Route → Alpine fetch() → DOM update
```

Livewire is only involved at the start (dispatching the job) and at the end (refreshing after completion). It never polls.

---

## Implementation

### 1. The JSON Route

Add a lightweight route that reads from the cache — no Livewire, no database queries.

**`plugins/webkul/channels/routes/web.php`**

```php
Route::middleware(['web', 'auth'])->get('/channels/{channelId}/sync-progress', function (int $channelId) {
    return response()->json([
        'syncing'  => (bool) Cache::get("shopify_sku_status_syncing_{$channelId}"),
        'progress' => Cache::get("shopify_sku_sync_progress_{$channelId}") ?? [
            'phase' => '', 'done' => 0, 'total' => 0, 'percent' => 0,
        ],
    ]);
})->name('channels.sync-progress');
```

### 2. The Queue Job — Writing Progress to Cache

The job writes structured progress data on every item processed. `Cache::put()` is fast enough to call on every iteration — the Shopify API call is the bottleneck, not the cache write.

**Key progress payload fields:**

```php
$writeProgress = function (string $phase, int $skuDone, int $skuTotal, int $locIndex, int $locTotal, int $phaseNum, int $phaseTotal) use ($progressKey): void {
    $phasePercent = $skuTotal > 0 ? min(99, (int) (($skuDone / $skuTotal) * 100)) : 0;
    Cache::put($progressKey, [
        'phase'         => $phase,           // Human-readable phase name
        'sku_done'      => $skuDone,         // Products processed in current phase
        'sku_total'     => $skuTotal,        // Total products in current phase
        'loc_index'     => $locIndex,        // Current location (for multi-location)
        'loc_total'     => $locTotal,
        'phase_num'     => $phaseNum,        // e.g. 1, 2, 3, 4
        'phase_total'   => $phaseTotal,      // Total phases (locations + prices + fetch)
        'phase_percent' => $phasePercent,    // Percentage within CURRENT phase only (0-99)
        'updated_at'    => now()->toIso8601String(), // For stale detection
    ], now()->addHours(2));
};
```

**Phase numbering example (2 Shopify locations):**
- Phase 1: Inventory → Location 1
- Phase 2: Inventory → Location 2
- Phase 3: Pushing prices
- Phase 4: Fetching from Shopify

`phase_percent` resets to 0 at the start of each phase and goes to 99% (never 100% — the job writes 100% only on full completion).

### 3. Job Cancellation via Cache Flag

Cancellation is handled by a cache flag checked at the top of every loop iteration.

```php
class SyncCancelledException extends \RuntimeException {}

$cancelKey = "shopify_sku_sync_cancel_{$channelId}";

$checkCancelled = function () use ($cancelKey): void {
    if (Cache::get($cancelKey)) {
        throw new SyncCancelledException();
    }
};

// In every foreach loop:
foreach ($skuMappings as $mapping) {
    $checkCancelled();
    // ... do work
}

// Wrap the whole job in try/catch:
try {
    // ... all phases
} catch (SyncCancelledException) {
    Log::info("Sync cancelled at {$done} for channel {$channelId}");
    Cache::put($progressKey, ['phase' => 'Cancelled', ...], now()->addMinutes(30));
} finally {
    Cache::forget("shopify_sku_status_syncing_{$channelId}");
    Cache::forget($cancelKey);
}
```

**Why a custom exception instead of `goto`?**  
PHP's `goto` cannot jump across `try` block boundaries. Throwing a typed exception and catching it separately is clean and reliable.

### 4. The Livewire Page Component

The page has two public properties that the Alpine component watches:

```php
public bool $syncingNow = false;
public array $syncProgressData = [];

public function mount($record): void
{
    $this->record = $this->resolveRecord($record);
    $this->refreshSyncState(); // Read current state from cache on page load
}

public function refreshSyncState(): void
{
    $id = $this->getRecord()->id;
    $this->syncingNow       = (bool) Cache::get("shopify_sku_status_syncing_{$id}");
    $this->syncProgressData = Cache::get("shopify_sku_sync_progress_{$id}") ?? [
        'phase' => '', 'done' => 0, 'total' => 0, 'percent' => 0,
    ];
}
```

**On sync start:**
```php
public function syncNow(): void
{
    Cache::put("shopify_sku_status_syncing_{$id}", true, now()->addHours(2));
    PushAndSyncSkuInventory::dispatch($this->getRecord()->id);

    $this->syncingNow       = true;
    $this->syncProgressData = ['phase' => 'Starting…', 'done' => 0, 'total' => 0, 'percent' => 0];
}
```

**On cancel:**
```php
public function cancelSyncAction(): Action
{
    return Action::make('cancelSync')
        ->requiresConfirmation()
        ->action(function () {
            Cache::put("shopify_sku_sync_cancel_{$id}", true, now()->addHours(2));
            Cache::forget("shopify_sku_status_syncing_{$id}");
            $this->syncingNow       = false;
            $this->syncProgressData = ['phase' => 'Cancelled', 'done' => 0, 'total' => 0, 'percent' => 0];
            $this->dispatch('sync-cancelled'); // ← Tell Alpine immediately
            Notification::make()->title('Sync cancelled')->warning()->send();
        });
}
```

### 5. The Alpine Component (Blade)

Define Alpine as a named function in a `<script>` tag — **not** inline `x-data`. Inline `x-data` with computed getters (`get propName()`) is unreliable and silently breaks `x-text` directives.

```html
<script>
function syncProgress(initialSyncing, initialData, channelId) {
    return {
        syncing:      initialSyncing,
        phase:        initialData.phase         || 'Starting…',
        phasePercent: initialData.phase_percent || 0,
        phaseNum:     initialData.phase_num     || 0,
        phaseTotal:   initialData.phase_total   || 0,
        skuDone:      initialData.sku_done      || 0,
        skuTotal:     initialData.sku_total     || 0,
        locIndex:     initialData.loc_index     || 0,
        locTotal:     initialData.loc_total     || 0,
        staleSecs:    0,
        updatedAt:    null,
        timer:        null,
        staleTimer:   null,

        init() {
            if (this.syncing) this.startPolling();

            // Watch Livewire property for sync start (e.g. triggered from another tab)
            this.$wire.$watch('syncingNow', (value) => {
                if (value && !this.syncing) {
                    this.syncing = true;
                    this.phase = 'Starting…';
                    this.phasePercent = 0; this.skuDone = 0; this.skuTotal = 0;
                    this.staleSecs = 0; this.updatedAt = null;
                    this.startPolling();
                }
            });

            // Listen for explicit cancel event from Livewire action
            this.$wire.on('sync-cancelled', () => {
                this.syncing = false;
                this.staleSecs = 0;
                clearInterval(this.timer); this.timer = null;
                clearInterval(this.staleTimer); this.staleTimer = null;
            });
        },

        startPolling() {
            if (this.timer) return; // prevent duplicate timers
            this.timer = setInterval(() => this.poll(), 2000);
            if (!this.staleTimer) {
                this.staleTimer = setInterval(() => {
                    if (this.updatedAt) {
                        this.staleSecs = Math.floor((Date.now() - this.updatedAt) / 1000);
                    }
                }, 1000);
            }
        },

        async poll() {
            try {
                const r = await fetch('/channels/' + channelId + '/sync-progress');
                const d = await r.json();
                this.syncing      = d.syncing;
                this.phase        = d.progress.phase         || this.phase;
                this.phasePercent = d.progress.phase_percent || 0;
                this.phaseNum     = d.progress.phase_num     || 0;
                this.phaseTotal   = d.progress.phase_total   || 0;
                this.skuDone      = d.progress.sku_done      || 0;
                this.skuTotal     = d.progress.sku_total     || 0;
                this.locIndex     = d.progress.loc_index     || 0;
                this.locTotal     = d.progress.loc_total     || 0;
                if (d.progress.updated_at) {
                    this.updatedAt = new Date(d.progress.updated_at).getTime();
                    this.staleSecs = 0;
                }
                if (!d.syncing) {
                    clearInterval(this.timer); this.timer = null;
                    clearInterval(this.staleTimer); this.staleTimer = null;
                    this.$wire.$refresh(); // Trigger final Livewire re-render
                }
            } catch(e) {}
        }
    };
}
</script>
```

**In the Blade template:**

```html
<div x-data="syncProgress({{ $syncing ? 'true' : 'false' }}, {{ json_encode($syncProgressData) }}, {{ $record->id }})">

    {{-- Progress bar (hidden until Alpine initialises via x-cloak) --}}
    <div x-show="syncing" x-cloak style="width:100%;padding-top:6px;">

        {{-- Phase header --}}
        <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px;">
            <span style="display:inline-flex;align-items:center;gap:6px;">
                <svg style="width:13px;height:13px;animation:spin 1s linear infinite;color:#92400e;" ...>...</svg>
                <strong style="font-size:.9rem;color:#92400e;" x-text="phase">Starting…</strong>
            </span>
            <span style="display:inline-flex;align-items:center;gap:10px;">
                <span x-show="phaseTotal > 0" x-cloak style="font-size:.78rem;color:#b45309;background:#fef3c7;border:1px solid #fde68a;border-radius:9999px;padding:1px 8px;font-weight:600;">
                    Phase <span x-text="phaseNum"></span> of <span x-text="phaseTotal"></span>
                </span>
                <strong x-show="phasePercent > 0" x-cloak style="font-size:.9rem;color:#92400e;" x-text="phasePercent + '%'"></strong>
            </span>
        </div>

        {{-- Progress bar track / fill --}}
        <div style="background:#fef3c7;border-radius:9999px;height:8px;overflow:hidden;width:100%;border:1px solid #fde68a;">
            {{-- IMPORTANT: use object syntax for :style to MERGE with existing styles --}}
            <div style="background:#d97706;height:100%;border-radius:9999px;transition:width .4s ease;"
                 :style="{ width: phasePercent + '%' }"></div>
        </div>

        {{-- Product count --}}
        <div x-show="skuTotal > 0" x-cloak style="font-size:.82rem;color:#92400e;font-weight:500;margin-top:5px;">
            <span x-text="skuDone.toLocaleString()"></span> of
            <span x-text="skuTotal.toLocaleString()"></span> products
            <span x-show="locTotal > 1">
                &middot; location <span x-text="locIndex"></span> of <span x-text="locTotal"></span>
            </span>
        </div>

        {{-- Stale warning (shows if no cache update for >15s) --}}
        <div x-show="staleSecs >= 15" x-cloak style="font-size:.82rem;color:#dc2626;font-weight:500;margin-top:4px;">
            No update for <span x-text="staleSecs"></span>s
            <span x-show="staleSecs >= 60"> — job may be stalled</span>
            <span x-show="staleSecs < 60"> — Shopify may be rate-limiting</span>
        </div>
    </div>
</div>
```

---

## Styling

```css
/* Hide Alpine elements until JS initialises — prevents flash of unstyled content */
[x-cloak] { display: none !important; }

/* Spinner animation (reuse anywhere) */
@keyframes spin { to { transform: rotate(360deg); } }
```

**Colour palette used:**
| Element | Background | Text/Border |
|---|---|---|
| Progress track | `#fef3c7` (yellow-100) | border `#fde68a` |
| Progress fill | `#d97706` (amber-600) | — |
| Phase text | — | `#92400e` (amber-800) |
| Phase badge | `#fef3c7` | `#b45309` border `#fde68a` |
| Stale warning | — | `#dc2626` (red-600) |

---

## Bugs Encountered & Fixes

### 1. `wire:poll` blocks Filament modal buttons

**Problem:** Using `wire:poll.2s="$refresh"` to update progress keeps Livewire in loading state, making modal action buttons (confirm/cancel) unclickable.

**Fix:** Remove all `wire:poll`. Move progress polling entirely to Alpine.js `setInterval` + `fetch`. Livewire is only called at start and end.

---

### 2. Computed getter syntax breaks `x-text`

**Problem:** Using `get propName() { ... }` inside an inline `x-data="{}"` object silently causes an Alpine error, breaking ALL `x-text` and `x-show` directives in the component. The SVG spinner still renders (it has no Alpine directives) but all text spans stay empty.

**Fix:** Move the Alpine component to a named function in a `<script>` tag and remove all computed getters. Replace them with inline expressions in the template.

```html
{{-- WRONG: computed getter breaks x-text --}}
<div x-data="{
    get label() { return this.count + ' items'; }
}">
    <span x-text="label"></span>  {{-- never renders --}}
</div>

{{-- RIGHT: named function, no getters --}}
<script>
function myComponent() {
    return {
        count: 0,
        // No getters — use x-text with expressions directly
    };
}
</script>
<div x-data="myComponent()">
    <span x-text="count + ' items'"></span>  {{-- works --}}
</div>
```

---

### 3. `:style` string replaces existing `style` attribute

**Problem:** `:style="'width:' + percent + '%'"` (string value) **replaces** the element's existing `style` attribute entirely, removing `background`, `height`, `border-radius`, etc. The progress fill becomes invisible.

**Fix:** Use object syntax — it **merges** with existing styles:

```html
{{-- WRONG: replaces all styles --}}
<div style="background:#d97706;height:100%;" :style="'width:' + percent + '%'"></div>

{{-- RIGHT: merges, only sets width --}}
<div style="background:#d97706;height:100%;" :style="{ width: percent + '%' }"></div>
```

---

### 4. Flash of unstyled content on page load

**Problem:** Before Alpine initialises (a few milliseconds on page load), all `x-show` conditions are unevaluated and all elements are visible. This causes a flash showing "Phase 0 of 0", "0 of 0 products", and the stale warning.

**Fix:** Add `x-cloak` to any element that should be hidden until Alpine is ready. Alpine removes the `x-cloak` attribute after initialisation.

```css
/* Add once to your page styles */
[x-cloak] { display: none !important; }
```

```html
<div x-show="phaseTotal > 0" x-cloak>...</div>
<div x-show="staleSecs >= 15" x-cloak>...</div>
```

---

### 5. `$wire.on('sync-cancelled')` vs `$wire.$watch`

**Problem:** After the cancel Filament action fires, `$wire.$watch('syncingNow', ...)` may not reliably trigger if Livewire morphs the Alpine component (creating a new instance). The old watch from the previous instance is lost and the progress bar stays visible.

**Fix:** Dispatch an explicit browser event from the cancel action so Alpine has a direct, reliable signal:

```php
// In the Livewire cancel action:
$this->dispatch('sync-cancelled');
```

```javascript
// In Alpine init():
this.$wire.on('sync-cancelled', () => {
    this.syncing = false;
    clearInterval(this.timer); this.timer = null;
    clearInterval(this.staleTimer); this.staleTimer = null;
});
```

---

### 6. PHP `goto` cannot jump across `try` blocks

**Problem:** Using `goto cancelled;` inside a nested `foreach` within a `try` block causes a PHP parse/runtime error.

**Fix:** Use a custom exception instead:

```php
class SyncCancelledException extends \RuntimeException {}

$checkCancelled = function () use ($cancelKey): void {
    if (Cache::get($cancelKey)) throw new SyncCancelledException();
};

try {
    foreach (...) {
        $checkCancelled();
    }
} catch (SyncCancelledException) {
    // handle cancellation
} finally {
    Cache::forget($syncingKey);
    Cache::forget($cancelKey);
}
```

---

### 7. Queue worker OPcache caches old job code

**Problem:** After editing a queue job, the running worker process has the old PHP file in OPcache. Cancellation code / new logic never runs.

**Fix:** Always restart the queue worker after changing job files:

```bash
php artisan queue:restart
php artisan queue:work
```

---

## Checklist for Reuse

- [ ] Add a `syncing` cache key (bool) and `progress` cache key (array) for the entity
- [ ] Add a `sync-progress` JSON route (no auth overhead beyond middleware)
- [ ] In the job: call `$writeProgress(...)` on every item (not every 10 — cache writes are cheap)
- [ ] In the job: include `updated_at` timestamp for stale detection
- [ ] In Livewire: expose `public bool $syncingNow` and `public array $syncProgressData`
- [ ] In Livewire cancel action: call `$this->dispatch('sync-cancelled')`
- [ ] In Blade: define Alpine as a named `<script>` function, not inline `x-data`
- [ ] Use `x-cloak` + `[x-cloak] { display:none !important; }` on all conditionally shown elements
- [ ] Use `:style="{ width: percent + '%' }"` (object) not `:style="'width:' + percent + '%'"` (string)
- [ ] Restart queue worker after any job code change
