Files
rose-ash/lib/host/playwright/boost-nav.spec.js
giles 7bec86289c web: morph preserves the boost's injected sx-* attrs — fix edit-page swap clobbering #content
Reported: logged in, go to an edit page, then press Home — nothing happens.

Root cause (browser-DOM trace): a boosted nav home→post morphs a home footer <a> into the
post's "edit" link (morph reuses nodes positionally). morph-node's sync-attrs then STRIPS any
attribute the old node has but the SERVER node lacks — which removes the boost's
client-injected sx-swap="innerHTML" (the server never sends it). With sx-swap gone the swap
defaults to outerHTML, so clicking edit REPLACES #content (the <div id=content>) with the edit
fragment's <div> (no id) — DOM trace: "sx-boost children [NAV, DIV#content]" → "[NAV, DIV]".
#content is destroyed, so every later boosted nav (Home) fetches but has no swap target
("post-swap: root=nil") → nothing updates.

Fix: sync-attrs no longer removes the boost's injected navigation attributes (sx-target /
sx-swap / sx-push-url / sx-get / sx-select) when the new (server) node lacks them — they're
identical across all boosted links, so a reused node keeps sx-swap="innerHTML" and the swap
morphs #content's children instead of replacing #content. Recompiled the web stack. Pairs
with a511b21d (fresh href) + 88f8b427 (SX-Redirect) — three facets of the morph-node-reuse
problem (stale href, lost swap attr, guarded-redirect clobber).

TEST-FIRST: added a 3rd boost-nav.spec.js case (LOGGED IN: home→post→edit, assert #content
survives + Home works). Reproduced RED via DOM traces (#content count 0), GREEN after — on the
ephemeral server AND live. No regressions: picker 3/3 + block-editor 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:17:59 +00:00

92 lines
4.9 KiB
JavaScript

// Regression for the boosted-navigation link-rebinding bug (reported on blog.rose-ash.com):
// home --boosted nav--> a post --click "edit"--> lands on /tags (a HOME footer link),
// not /<slug>/edit. After a boost swap, the swapped-in links carry a STALE binding from
// the previous page. Run by run-boost-nav-check.sh against an ephemeral host server
// (serve.sh seeds /compose-demo + the home footer's /tags link).
const { test, expect } = require('playwright/test');
const BASE = process.env.SX_TEST_URL || 'http://127.0.0.1:8914';
const USER = process.env.SX_ADMIN_USER || 'admin';
const PASS = process.env.SX_ADMIN_PASSWORD || 'letmein';
async function waitReady(page) {
await expect(page.locator('html[data-sx-ready="true"]')).toHaveCount(1, { timeout: 45000 });
}
async function login(page) {
await page.goto(BASE + '/login');
await page.fill('input[name="username"]', USER);
await page.fill('input[name="password"]', PASS);
await page.click('button[type="submit"]');
await page.waitForURL((u) => !u.pathname.startsWith('/login'));
}
test.describe('boosted navigation (browser-only)', () => {
test('a post link clicked AFTER a boosted nav navigates to the right target (not a stale home link)', async ({ page }) => {
test.setTimeout(90000);
// 1) load HOME (its footer has a /tags link — the stale target the bug lands on)
await page.goto(BASE + '/');
await waitReady(page);
await expect(page.locator('a[href="/tags"]')).toHaveCount(1); // home has the /tags link
// 2) boosted nav HOME -> the composed post (no full reload)
await page.locator('a[href="/compose-demo/"]').first().click();
await expect(page.locator('body')).toContainText('composition object', { timeout: 15000 });
expect(page.url()).toContain('/compose-demo/');
// 3) click the post's "edit" link — brought in by the swap
await expect(page.locator('a[href="/compose-demo/edit"]')).toHaveCount(1);
await page.locator('a[href="/compose-demo/edit"]').click();
await page.waitForTimeout(3000);
// 4) it MUST navigate to the edit route (guarded -> the login view is fine, the URL is
// pushed to /compose-demo/edit), and MUST NOT land on the stale /tags link.
expect(page.url()).not.toContain('/tags');
expect(page.url()).toContain('/compose-demo/edit');
});
test('a guarded route reached via boost does a clean full-nav to /login (no clobbered SPA), and Home works from there', async ({ page }) => {
test.setTimeout(90000);
await page.goto(BASE + '/');
await waitReady(page);
// boosted nav home -> post
await page.locator('a[href="/compose-demo/"]').first().click();
await expect(page.locator('body')).toContainText('composition object', { timeout: 15000 });
// click "edit" (guarded, logged out). A 303 would be followed by the fetch WITHOUT the
// SX-Request header -> /login returns the full shell, which morphed into #content
// DESTROYS the swap target (then nothing navigates). The fix returns SX-Redirect, so the
// engine does a FULL navigation to a real /login page.
await page.locator('a[href="/compose-demo/edit"]').click();
await page.waitForTimeout(3500);
expect(new URL(page.url()).pathname).toBe('/login');
await expect(page.locator('body')).toContainText('Log in');
// and the login page offers a way back Home that works (the reported "Home does nothing").
await page.locator('a[href="/"]').first().click();
await page.waitForTimeout(3000);
await expect(page.locator('body')).toContainText('Posts', { timeout: 12000 });
expect(new URL(page.url()).pathname).toBe('/');
});
test('LOGGED IN: the Home nav works after a boosted nav to the edit page', async ({ page }) => {
test.setTimeout(90000);
await login(page); // authed session
await page.goto(BASE + '/');
await waitReady(page);
// boosted nav home -> post -> edit (authed, so the real edit form swaps into #content)
await page.locator('a[href="/compose-demo/"]').first().click();
await expect(page.locator('body')).toContainText('composition object', { timeout: 15000 });
// the footer "edit" link (there's also a "no relations — add some" link to edit when authed)
await page.locator('a[href="/compose-demo/edit"]').last().click();
await expect(page.locator('body')).toContainText('Edit:', { timeout: 15000 });
expect(new URL(page.url()).pathname).toBe('/compose-demo/edit');
// #content must SURVIVE the edit swap (an outerHTML swap would replace it, then no later
// nav can find a swap target — the reported "Home does nothing").
expect(await page.locator('#content').count()).toBe(1);
// the persistent top-nav Home link must still work on the edit page.
await page.locator('nav a[href="/"]').first().click();
await page.waitForTimeout(3000);
await expect(page.locator('body')).toContainText('Posts', { timeout: 12000 });
expect(new URL(page.url()).pathname).toBe('/');
});
});