From 88f8b427c515abc1035c9427d8ed7618b6cfc06a Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 1 Jul 2026 06:55:32 +0000 Subject: [PATCH] =?UTF-8?q?host:=20guarded=20route=20via=20boost=20?= =?UTF-8?q?=E2=86=92=20SX-Redirect=20(full=20nav=20to=20/login),=20not=20a?= =?UTF-8?q?=20#content-clobbering=20303?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: go to an edit page, then press Home — nothing happens; navigation stops updating. Root cause (found via a browser trace): a guarded route (host/require-login) answered a BOOSTED (SX-Request) request with a 303 to /login. The browser's fetch follows the redirect but DROPS the SX-Request header on the way, so /login returned the full HTML shell (…), not a text/sx fragment. Morphing that whole document into #content DESTROYS the #content swap target (diagnostic: "#content count: 0"), so every later boosted nav fetches but has nowhere to swap ("post-swap: root=nil") — the persistent nav Home appears to do nothing. Fix (host-side, no engine change — the engine already supports SX-Redirect): for a boosted request require-login now returns 200 + an `SX-Redirect: /login?next=…` header. The engine does a FULL navigation (browser-navigate) to a real /login page — #content is never clobbered. Non-boosted requests still get a plain 303. Also added a "← Home" link to the login shell (it's a standalone page with no persistent nav, so a logged-out user who followed a guarded link was otherwise stranded — the literal "press Home" case). TEST-FIRST: added a second boost-nav.spec.js case (home → post → click edit → assert clean full-nav to /login, NOT a clobbered SPA, and Home works from there). Confirmed RED before (Home did nothing on the clobbered page), GREEN after — verified on the ephemeral server AND live. No regressions: picker 3/3 + block-editor 1/1 (login flow intact). Co-Authored-By: Claude Opus 4.8 --- lib/host/auth.sx | 16 ++++++++++++++-- lib/host/playwright/boost-nav.spec.js | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/host/auth.sx b/lib/host/auth.sx index eca0b994..499808de 100644 --- a/lib/host/auth.sx +++ b/lib/host/auth.sx @@ -48,7 +48,10 @@ (input :type "hidden" :name "next" :value (unquote next-path)) (p (input :name "username" :placeholder "username")) (p (input :name "password" :type "password" :placeholder "password")) - (p (button :type "submit" "Log in")))))))) + (p (button :type "submit" "Log in"))) + ;; a way back into the app — the login shell is a standalone page (no persistent + ;; nav), so without this a logged-out user who followed a guarded link is stranded. + (p :style "margin-top:1em" (a :href "/" "← Home"))))))) ;; ── GET /login — login form, honouring ?next= (where to go after login) ───── (define host/login-page @@ -137,5 +140,14 @@ (fn (req) (let ((principal (host/-principal-of req resolve))) (if (or (nil? principal) (= principal "")) - (dream-redirect (str "/login?next=" (host/-safe-next (dream-path req)))) + (let ((login-url (str "/login?next=" (host/-safe-next (dream-path req))))) + ;; A BOOSTED (SX-Request) request can't be answered with a 303: the browser's + ;; fetch follows the redirect WITHOUT the SX-Request header, so /login returns + ;; the full HTML shell, which morphed into #content DESTROYS the SPA swap target + ;; (every later boosted nav then has nowhere to swap — "nothing happens"). Return + ;; an SX-Redirect header instead — the engine does a FULL navigation to /login (a + ;; fresh shell). A non-boosted request still gets a plain 303. + (if (= (dream-header req "sx-request") "true") + (dream-response 200 {:sx-redirect login-url} "") + (dream-redirect login-url))) (next (assoc req :dream-principal principal)))))))) diff --git a/lib/host/playwright/boost-nav.spec.js b/lib/host/playwright/boost-nav.spec.js index 59506e39..028e522c 100644 --- a/lib/host/playwright/boost-nav.spec.js +++ b/lib/host/playwright/boost-nav.spec.js @@ -34,4 +34,26 @@ test.describe('boosted navigation (browser-only)', () => { 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('/'); + }); });