From fdd86ceb03ec0112058043321257399ffad8fae6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 1 Sep 2026 09:19:00 -0700 Subject: [PATCH] Stop server-rendered landings flashing for signed-in users (#3707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell renders its anonymous markup — the marketing homepage, an `/app/` landing — off the session cookie alone, and that cookie is set with no maxAge, so a browser drops it on quit while the GUI's localStorage token lives on. A returning user is served the anonymous page and the GUI only tears it down once `whoami` answers, a network round-trip after first paint. That teardown is the flash. Gate it before the paint instead. The shell now emits, as the first thing in , a rule hiding `.hide-if-logged-in` under an class that an inline script adds iff `auth_token_v2` is in localStorage. The rule is already in the cascade when the markup is parsed, so a browser holding a token never paints it at all. `initgui` settles the guess the token represents: `whoami` confirming the session removes the nodes outright (replacing the old `#appLanding` removal), and no session — none stored, or one `whoami` rejected — drops the class so the markup comes back. The gate carries its own 12s failsafe so a bundle that never boots can't strand a blank page. SEO is unaffected: the HTML is byte-identical for every client, nothing branches on user-agent, and a crawler has no stored token so it never adds the class. Unreadable storage fails open the same way. Anonymous markup opts in with `class="hide-if-logged-in"`, which `home.html` already carried. --- .../homepage/PuterHomepageService.test.ts | 147 ++++++++++++++++++ .../services/homepage/PuterHomepageService.ts | 52 +++++++ src/gui/src/initgui.js | 60 ++++++- 3 files changed, 251 insertions(+), 8 deletions(-) diff --git a/src/backend/services/homepage/PuterHomepageService.test.ts b/src/backend/services/homepage/PuterHomepageService.test.ts index 503167992..699039956 100644 --- a/src/backend/services/homepage/PuterHomepageService.test.ts +++ b/src/backend/services/homepage/PuterHomepageService.test.ts @@ -401,3 +401,150 @@ describe('PuterHomepageService — head metadata', () => { expect(html).toContain(''); }); }); + +describe('PuterHomepageService — pre-paint session gate', () => { + /** The gate's inline script, extracted so it can be run against a fake DOM. */ + const gateScript = (html: string): string => { + const head = html.slice(0, html.indexOf('')); + const block = [...head.matchAll(/`; + /** * Serves the root HTML shell that bootstraps the Puter GUI. * @@ -271,6 +322,7 @@ export class PuterHomepageService extends PuterService { ${e(title)} + ${SESSION_GATE} ${event.prependHeadContent} diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 00888a6c1..d22b6239f 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -1010,6 +1010,40 @@ function authErrorDisplayMessage() { return i18n('auth_error_generic', [], false); } +/** + * Settles the shell's server-rendered anonymous markup — the marketing + * homepage, an `/app/` landing — once the client knows whether it has a + * session. + * + * The shell renders that markup from the session cookie alone, and a browser + * drops that cookie on quit while our localStorage token lives on. So a + * returning user gets served it, and `PuterHomepageService`'s `` gate + * hides it pre-paint on the strength of the stored token (the + * `has-stored-session` class) rather than letting it flash and be torn down a + * round-trip later. + * + * That gate is a guess about a token nothing has verified yet. Here is where it + * is settled: + * + * - `reveal(false)` — `whoami` confirmed the session. The markup is wrong for + * this user, so remove the nodes outright; the class can stay. + * - `reveal(true)` — there is no session after all (no token, or one `whoami` + * rejected). The markup is the correct thing to show, so drop the class. + * + * @param {boolean} reveal Whether the markup should end up visible. + */ +function reveal_anonymous_markup(reveal) { + // Stands the gate's own failsafe timer down: the guess has been ruled on. + window.__puter_session_settled = true; + if (reveal) { + document.documentElement.classList.remove('has-stored-session'); + return; + } + document + .querySelectorAll('.hide-if-logged-in') + .forEach((el) => el.remove()); +} + /** * Shows a Turnstile challenge modal for first-time temp user creation * @param {Object} options - Configuration options @@ -1778,6 +1812,10 @@ window.initgui = async function (options) { * and without authenticating with the server. */ const bad_session_logout = async () => { + // The gate hid the anonymous markup on the strength of a stored + // token that has just turned out to be dead. Put it back, so the alert + // below isn't sitting on an empty page. + reveal_anonymous_markup(true); try { // TODO: i18n await UIAlert({ @@ -1963,14 +2001,15 @@ window.initgui = async function (options) { } // update local user data if (whoami) { - // The server renders the /app/ landing overlay only for - // requests it saw as anonymous, but its only signal is the session - // cookie — which can be gone (e.g. browser restart) while the - // localStorage session is still valid. whoami just proved this is - // a logged-in user, so drop the overlay. This must happen before - // the verification gates below: the overlay's max z-index would - // cover them. - document.getElementById('appLanding')?.remove(); + // The shell renders its anonymous markup — the marketing + // homepage, an /app/ landing — off the session cookie alone, + // and that cookie can be gone (e.g. browser restart) while the + // localStorage session is still valid. The shell's gate has + // kept it from painting; whoami just proved this is a logged-in + // user, so drop it for good. This must happen before the + // verification gates below: the landing's max z-index would cover + // them. + reveal_anonymous_markup(false); // Verification gates run in order: email → phone (SMS) → card, // matching the server-side order in assertVerifiedAccount. if (whoami.requires_email_confirmation) { @@ -2056,6 +2095,11 @@ window.initgui = async function (options) { // ------------------------------------------------------------------------------------- // Un-authed but not first visit -> try to log in/sign up // ------------------------------------------------------------------------------------- + // No session after all: the anonymous markup the shell sent is the correct + // thing to show, so undo its gate in case a stored token set it and + // then failed to authenticate. + if (!window.is_auth()) reveal_anonymous_markup(true); + // App landing pages (`/app/`, incl. `/desktop/app/`) require a // real account even on a first visit — never a temp user. So does a share // link: a share only ever reaches a real account, so a temporary one could