fix(live): persist nav-pin state across refresh (#1510) (#1515)

## What was broken

The nav-pin button state was not persisted across page loads. Every
refresh reset the nav to unpinned regardless of what the user had set,
forcing them to re-pin on every visit.

## What was added

- On init: reads `localStorage.getItem('live-nav-pinned')` and restores
the pinned state into `_navCleanup.pinned` before the button is created;
if pinned, the button gets the `pinned` class, `aria-pressed="true"`,
and `nav-autohide` is removed from the nav.
- On click: after toggling, writes
`localStorage.setItem('live-nav-pinned', _navCleanup.pinned)` inside a
`try/catch` (quota guard, consistent with other live.js localStorage
writes).

localStorage key: `live-nav-pinned`

Closes #1510

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
efiten
2026-05-31 14:54:24 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 3850600130
commit 878d162b71
3 changed files with 121 additions and 1 deletions
+1
View File
@@ -363,6 +363,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1234-live-chrome-pass2-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1510-live-nav-pin-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
+8 -1
View File
@@ -2132,7 +2132,8 @@
// Auto-hide nav with pin toggle (#62)
const topNav = document.querySelector('.top-nav');
if (topNav) { topNav.style.position = 'fixed'; topNav.style.width = '100%'; topNav.style.zIndex = '1100'; }
_navCleanup = { timeout: null, fn: null, pinned: false };
const _savedPin = localStorage.getItem('live-nav-pinned') === 'true';
_navCleanup = { timeout: null, fn: null, pinned: _savedPin };
// Add pin button to nav (guard against duplicate)
if (topNav && !document.getElementById('navPinBtn')) {
const pinBtn = document.createElement('button');
@@ -2146,6 +2147,7 @@
_navCleanup.pinned = !_navCleanup.pinned;
pinBtn.classList.toggle('pinned', _navCleanup.pinned);
pinBtn.setAttribute('aria-pressed', _navCleanup.pinned);
try { localStorage.setItem('live-nav-pinned', _navCleanup.pinned); } catch (_) {}
if (_navCleanup.pinned) {
clearTimeout(_navCleanup.timeout);
topNav.classList.remove('nav-autohide');
@@ -2153,6 +2155,11 @@
_navCleanup.timeout = setTimeout(() => { topNav.classList.add('nav-autohide'); }, 4000);
}
});
if (_navCleanup.pinned) {
pinBtn.classList.add('pinned');
pinBtn.setAttribute('aria-pressed', 'true');
topNav.classList.remove('nav-autohide');
}
topNav.appendChild(pinBtn);
}
function showNav() {
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* E2E for #1510 live-page nav pin must persist across refresh.
*
* Symptom: clicking #navPinBtn pinned the nav, but reloading the page reset
* pinned=false regardless. Fix: store 'live-nav-pinned' in localStorage and
* restore on init.
*
* Acceptance:
* 1. Clicking #navPinBtn adds class 'pinned' and sets localStorage.
* 2. After page reload the pin button still has class 'pinned' (restored from storage).
* 3. Clicking again removes 'pinned'; reload leaves button unpinned.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const LS_KEY = 'live-nav-pinned';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
async function gotoLive(page) {
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#navPinBtn', { timeout: 10000 });
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
}
async function main() {
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (process.env.CHROMIUM_REQUIRE === '1') {
console.error('test-issue-1510-live-nav-pin-e2e.js: FAIL — Chromium unavailable: ' + err.message);
process.exit(1);
}
console.log('test-issue-1510-live-nav-pin-e2e.js: SKIP (Chromium unavailable: ' + err.message.split('\n')[0] + ')');
process.exit(0);
}
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(12000);
// Clear any prior state.
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.evaluate((k) => localStorage.removeItem(k), LS_KEY);
// (1) Initial state: pin button exists and is unpinned.
await step('pin button renders without pinned class on first load', async () => {
await gotoLive(page);
const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned'));
assert(!pinned, 'pin button must not have class "pinned" before first click');
const stored = await page.evaluate((k) => localStorage.getItem(k), LS_KEY);
assert(stored !== 'true', 'localStorage must not be "true" before first click');
});
// (2) Click pin → class added, localStorage updated.
await step('click sets pinned class and persists to localStorage', async () => {
await page.$eval('#navPinBtn', btn => btn.click());
await page.evaluate(() => new Promise(r => requestAnimationFrame(r)));
const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned'));
assert(pinned, 'pin button must have class "pinned" after click');
const stored = await page.evaluate((k) => localStorage.getItem(k), LS_KEY);
assert(stored === 'true', `localStorage["${LS_KEY}"] must be "true", got "${stored}"`);
});
// (3) Reload → pin state restored from localStorage.
await step('pin state survives page reload', async () => {
await gotoLive(page);
const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned'));
assert(pinned, 'pin button must have class "pinned" after reload (restored from localStorage)');
});
// (4) Click again to unpin → class removed, localStorage updated.
await step('second click removes pinned class and updates localStorage', async () => {
await page.$eval('#navPinBtn', btn => btn.click());
await page.evaluate(() => new Promise(r => requestAnimationFrame(r)));
const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned'));
assert(!pinned, 'pin button must not have class "pinned" after unpin click');
const stored = await page.evaluate((k) => localStorage.getItem(k), LS_KEY);
assert(stored === 'false', `localStorage["${LS_KEY}"] must be "false", got "${stored}"`);
});
// (5) Reload after unpin → button is not pinned.
await step('unpinned state survives page reload', async () => {
await gotoLive(page);
const pinned = await page.$eval('#navPinBtn', el => el.classList.contains('pinned'));
assert(!pinned, 'pin button must not have class "pinned" after reload in unpinned state');
});
await browser.close();
const total = passed + failed;
console.log(`\ntest-issue-1510-live-nav-pin-e2e.js: ${failed === 0 ? 'OK' : 'FAIL'}${passed}/${total} passed`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch(err => {
console.error('test-issue-1510-live-nav-pin-e2e.js: ERROR', err);
process.exit(1);
});