fix: flashs de navigation — la zone swapée n'est plus peint non montée (v7.45.3)
- Symptôme : à chaque changement de page/section, une rafale de fenêtres / menus / états s'affiche une fraction de seconde avant la page voulue. - Cause : pendant la fenêtre x-ignore (swap fdLoad → Alpine.initTree, mise en place en v7.45.2), .main-wrapper était peint NON montée : tous ses [x-show] visibles à leur valeur brute, puis masqués d'un coup au montage. Mesure (e2e/probe_nav_perf.spec.js sur l'instance locale) : 19 éléments bruts visibles 87 ms sur /library (CLS 0.149, sources lib-loading/lib-empty/ lib-table), 80 éléments pendant 732 ms sur /settings, 7 sur /workspaces. - Fix (app.js, handler htmx:afterSwap) : opacity:0 + pointer-events:none posés dans la même task que le swap (aucun paint intermédiaire possible), retirés APRÈS Alpine.initTree sur les 3 chemins de démontage (scripts chargés, zéro script, filet 4 s). reveal() est appelé avant les early-returns de done() → impossible de rester bloqué invisible. - Après : 0 frame de contenu brut peint sur les 4 navigations du probe, 1–2 frames masquées, zone révélée (opacity:1, pointer-events:auto) et montée (0 [x-show] non montés, x-ignore absent) à chaque fois. - Gate : e2e/probe_nav_perf.spec.js — marques htmx, fenêtre x-ignore, échantillon par frame des [x-show] bruts peints, layout-shift + assertion (0 brut / zone révélée et montée), exécutable en CI. - Reste (non traité, voir ROADMAP) : CLS résiduel 0.057 sur /library (états loading → table), fenêtre x-ignore de ~250 ms sur /settings (coût des scripts de page), topbar incluse dans .main-wrapper re-swappée à chaque nav. Gates : probe_nav_perf + regression_partial_nav + regression_editor_mount + regression_logout_dnd + smoke · pytest 1094 passed · ruff OK · OpenAPI 7.45.3.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Probe de fluidité de navigation partielle (fdLoad) — mesure brute, pas un test gate.
|
||||
*
|
||||
* Pour chaque navigation il enregistre :
|
||||
* - les marques htmx (beforeRequest / beforeSwap / afterSwap / afterSettle)
|
||||
* - la fenêtre x-ignore (zone non montée Alpine)
|
||||
* - un échantillon par frame : nb d'éléments [x-show] ENCORE VISIBLES non montés
|
||||
* (= le contenu « brut » qui se voit avant qu'Alpine ne le masque → les flashes
|
||||
* de fenêtres) et nb d'éléments [x-cloak] encore masqués (= contenu qui pop)
|
||||
* - les layout-shift (CLS) et leur source
|
||||
*
|
||||
* Lancement : npx playwright test probe_nav_perf --reporter=list
|
||||
*/
|
||||
const { test } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Instrumentation posée UNE fois dans la page (avant le clic).
|
||||
const INSTALL = () => {
|
||||
const P = (window.__probe = { t0: performance.now(), marks: [], frames: [], shifts: [], rawMax: 0, rawFrames: 0 });
|
||||
const mark = (n) => P.marks.push([n, Math.round(performance.now() - P.t0)]);
|
||||
|
||||
['htmx:beforeRequest', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:afterSettle'].forEach((ev) =>
|
||||
document.addEventListener(ev, () => mark(ev))
|
||||
);
|
||||
|
||||
// Fenêtre x-ignore sur .main-wrapper (le nœud change à chaque swap → on observe tout le doc)
|
||||
new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
if (m.type !== 'attributes' || m.attributeName !== 'x-ignore') continue;
|
||||
const t = m.target;
|
||||
if (!(t instanceof Element) || !t.classList.contains('main-wrapper')) continue;
|
||||
mark(t.hasAttribute('x-ignore') ? 'x-ignore ON' : 'x-ignore OFF');
|
||||
}
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['x-ignore'], subtree: true });
|
||||
|
||||
new PerformanceObserver((list) => {
|
||||
for (const e of list.getEntries()) {
|
||||
P.shifts.push({
|
||||
v: +e.value.toFixed(4),
|
||||
t: Math.round(e.startTime - P.t0),
|
||||
sources: (e.sources || []).map((s) => {
|
||||
const n = s.node;
|
||||
return n ? `${n.nodeName}.${(n.className || '').toString().split(' ')[0]}` : '?';
|
||||
}),
|
||||
});
|
||||
}
|
||||
}).observe({ type: 'layout-shift', buffered: false });
|
||||
|
||||
// Échantillonnage par frame. « visible » = peint à l'écran : la zone peut
|
||||
// porter opacity:0 (fix v7.45.3) → rects toujours là, mais rien n'est peint.
|
||||
const zonePainted = (z) => z && parseFloat(getComputedStyle(z).opacity || '1') > 0;
|
||||
const tick = () => {
|
||||
let raw = 0, rawVisible = 0, cloak = 0;
|
||||
const zone = document.querySelector('.main-wrapper');
|
||||
const painted = zonePainted(zone);
|
||||
if (zone) {
|
||||
for (const el of zone.querySelectorAll('[x-show]')) {
|
||||
if (el._x_marker) continue; // monté par Alpine
|
||||
raw++;
|
||||
if (painted && el.getClientRects().length) rawVisible++; // peint ET en flow
|
||||
}
|
||||
cloak = zone.querySelectorAll('[x-cloak]').length;
|
||||
}
|
||||
P.rawMax = Math.max(P.rawMax, rawVisible);
|
||||
if (rawVisible) P.rawFrames++;
|
||||
P.frames.push([Math.round(performance.now() - P.t0), raw, rawVisible, cloak, painted ? 1 : 0]);
|
||||
if (performance.now() - P.t0 < 3000) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
return true;
|
||||
};
|
||||
|
||||
test('probe fluidité nav', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await login(page);
|
||||
|
||||
const NAVS = ['/library', '/settings', '/workspaces', '/trash'];
|
||||
const results = [];
|
||||
|
||||
for (const dest of NAVS) {
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.evaluate(INSTALL);
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'probe';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, dest);
|
||||
await page.waitForTimeout(3200);
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
// État final : la zone doit être révélée (opacity>0) ET montée Alpine
|
||||
const final = await page.evaluate(() => {
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
if (!z) return { opacity: 'NO-ZONE', unmounted: -1, xIgnore: null };
|
||||
return {
|
||||
opacity: getComputedStyle(z).opacity,
|
||||
pointerEvents: getComputedStyle(z).pointerEvents,
|
||||
unmounted: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
|
||||
xIgnore: z.hasAttribute('x-ignore'),
|
||||
};
|
||||
});
|
||||
|
||||
// Résumé : première et dernière frame où du contenu brut visible subsiste
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const results_summary = {
|
||||
dest,
|
||||
marks: Object.fromEntries(P.marks),
|
||||
rawVisibleFrames: rawFrames.length,
|
||||
rawFirst: rawFrames.length ? rawFrames[0] : null,
|
||||
rawLast: rawFrames.length ? rawFrames[rawFrames.length - 1] : null,
|
||||
rawMaxElements: P.rawMax,
|
||||
hiddenFrames: P.frames.filter((f) => f[4] === 0).length,
|
||||
cloakMax: Math.max(0, ...P.frames.map((f) => f[3])),
|
||||
shifts: P.shifts,
|
||||
final,
|
||||
frameCount: P.frames.length,
|
||||
};
|
||||
results.push(results_summary);
|
||||
console.log('NAV ' + dest + ' => ' + JSON.stringify(results_summary));
|
||||
}
|
||||
|
||||
// GATE : aucun contenu brut peint + zone révélée et montée à la fin
|
||||
const paintedRaw = results.filter((r) => r.rawVisibleFrames > 0);
|
||||
const notRevealed = results.filter((r) => parseFloat(r.final.opacity) !== 1 || r.final.unmounted !== 0 || r.final.xIgnore);
|
||||
console.log('SOMMAIRE ' + JSON.stringify(results.map((r) => ({ d: r.dest, raw: r.rawVisibleFrames, hidden: r.hiddenFrames, op: r.final.opacity, un: r.final.unmounted, shift: r.shifts.length }))));
|
||||
if (paintedRaw.length) throw new Error('contenu brut peint: ' + paintedRaw.map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMaxElements})`).join(' ; '));
|
||||
if (notRevealed.length) throw new Error('zone non révélée/montée: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
|
||||
console.log('GATE OK — 0 frame de contenu brut, zone révélée et montée sur ' + results.length + ' navigations');
|
||||
});
|
||||
Reference in New Issue
Block a user