Stop server-rendered landings flashing for signed-in users (#3707)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

The shell renders its anonymous markup — the marketing homepage, an
`/app/<name>` 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 <head>, a rule hiding `.hide-if-logged-in` under an <html> 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.
This commit is contained in:
Nariman Jelveh
2026-09-01 09:19:00 -07:00
committed by GitHub
parent 7584dffc4b
commit fdd86ceb03
3 changed files with 251 additions and 8 deletions
@@ -401,3 +401,150 @@ describe('PuterHomepageService — head metadata', () => {
expect(html).toContain('<meta name="description" content="long">');
});
});
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('</head>'));
const block = [...head.matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map((m) => m[1])
.find((body) => body.includes('has-stored-session'));
if (!block) throw new Error('no session gate script in rendered head');
return block;
};
/** Run the gate with a given localStorage, and report the <html> classes. */
const runGate = (
html: string,
storage: { getItem: (k: string) => string | null },
): string[] => {
const classes = new Set<string>();
const documentElement = {
classList: {
add: (c: string) => classes.add(c),
remove: (c: string) => classes.delete(c),
},
};
// eslint-disable-next-line no-new-func
new Function(
'localStorage',
'document',
'window',
gateScript(html),
)(storage, { documentElement }, { addEventListener: () => {} });
return [...classes];
};
it('hides opted-in markup for a browser holding a session token', async () => {
const html = await render(makeService());
expect(html).toContain(
'html.has-stored-session .hide-if-logged-in{display:none!important}',
);
expect(
runGate(html, { getItem: (k) => (k === 'auth_token_v2' ? 'tok' : null) }),
).toEqual(['has-stored-session']);
});
it('leaves the markup visible for anonymous visitors and crawlers', async () => {
const html = await render(makeService());
expect(runGate(html, { getItem: () => null })).toEqual([]);
});
it('ignores a value under the retired token key', async () => {
const html = await render(makeService());
expect(
runGate(html, { getItem: (k) => (k === 'auth_token' ? 'old' : null) }),
).toEqual([]);
});
it('fails open when storage is unreadable', async () => {
const html = await render(makeService());
expect(
runGate(html, {
getItem: () => {
throw new Error('storage blocked');
},
}),
).toEqual([]);
});
it('hands the markup back if the GUI never settles the guess', async () => {
const html = await render(makeService());
const classes = new Set<string>(['has-stored-session']);
const listeners: Array<() => void> = [];
const timers: Array<() => void> = [];
const win: Record<string, unknown> = {
addEventListener: (_e: string, fn: () => void) => listeners.push(fn),
};
// eslint-disable-next-line no-new-func
new Function(
'localStorage',
'document',
'window',
'setTimeout',
gateScript(html),
)(
{ getItem: () => 'tok' },
{
documentElement: {
classList: {
add: (c: string) => classes.add(c),
remove: (c: string) => classes.delete(c),
},
},
},
win,
(fn: () => void) => timers.push(fn),
);
listeners.forEach((fn) => fn()); // window 'load'
timers.forEach((fn) => fn()); // the failsafe deadline
expect([...classes]).toEqual([]);
// ...and stands down once `initgui` has ruled on the token.
const settled = new Set<string>(['has-stored-session']);
const settledTimers: Array<() => void> = [];
const settledListeners: Array<() => void> = [];
// eslint-disable-next-line no-new-func
new Function(
'localStorage',
'document',
'window',
'setTimeout',
gateScript(html),
)(
{ getItem: () => 'tok' },
{
documentElement: {
classList: {
add: (c: string) => settled.add(c),
remove: (c: string) => settled.delete(c),
},
},
},
{
addEventListener: (_e: string, fn: () => void) =>
settledListeners.push(fn),
__puter_session_settled: true,
},
(fn: () => void) => settledTimers.push(fn),
);
settledListeners.forEach((fn) => fn());
settledTimers.forEach((fn) => fn());
expect([...settled]).toEqual(['has-stored-session']);
});
it('renders the gate ahead of any extension-contributed markup', async () => {
const service = makeService({}, async (_key, event) => {
const e = event as { prependHeadContent: string; prependBodyContent: string };
e.prependHeadContent += '<meta name="from-extension">';
e.prependBodyContent += '<main class="hide-if-logged-in">landing</main>';
});
const html = await render(service);
expect(html.indexOf('has-stored-session')).toBeLessThan(
html.indexOf('<meta name="from-extension">'),
);
expect(html.indexOf('has-stored-session')).toBeLessThan(
html.indexOf('<main class="hide-if-logged-in">'),
);
});
});
@@ -64,6 +64,57 @@ interface PuterGuiAddonsEvent {
prependBodyContent: string;
}
/**
* Pre-paint gate for server-rendered anonymous markup.
*
* The shell is rendered from the session cookie alone, and a browser drops that
* cookie on quit while the GUI's localStorage token lives on. So a returning
* user is served the anonymous markup extensions splice in - the marketing
* homepage, an `/app/<name>` landing - and the GUI only tears it down once
* `whoami` answers, a network round-trip after first paint. That teardown is
* the flash.
*
* This runs in `<head>`, before any of that markup is parsed, so a browser
* holding a token never paints it: the rule is already in the cascade when the
* element arrives. `initgui` removes the nodes for good once `whoami` confirms
* the session, and puts them back if it doesn't (see `has-stored-session`
* there).
*
* Anonymous markup opts in with `class="hide-if-logged-in"`.
*
* SEO: the HTML is byte-identical for every client - nothing branches on
* user-agent. A crawler has no stored token, so it never adds the class and
* sees the landing exactly as served. Storage being unreadable (private modes,
* blocked site data) fails open the same way.
*
* The key is the one `gui/src/globals.js` boots `window.auth_token` from
* (`AUTH_TOKEN_KEY_V2`); the retired `auth_token` key can no longer
* authenticate, so a value under it must not hide anything.
*/
const SESSION_GATE = `
<style>html.has-stored-session .hide-if-logged-in{display:none!important}</style>
<script>
(function () {
try {
if (!localStorage.getItem('auth_token_v2')) return;
} catch (e) {
// Storage unreadable - fail open and show the markup.
return;
}
document.documentElement.classList.add('has-stored-session');
// Self-healing: the token is only a guess until \`whoami\` rules on it,
// and \`initgui\` is what settles the guess. If it never gets there -
// bundle blocked, boot threw - hiding the markup would leave a blank
// page for good, so hand it back.
window.addEventListener('load', function () {
setTimeout(function () {
if (window.__puter_session_settled) return;
document.documentElement.classList.remove('has-stored-session');
}, 12000);
});
})();
</script>`;
/**
* Serves the root HTML shell that bootstraps the Puter GUI.
*
@@ -271,6 +322,7 @@ export class PuterHomepageService extends PuterService {
<html lang="en">
<head>
<title>${e(title)}</title>
${SESSION_GATE}
${event.prependHeadContent}
<link rel="preload" href="${guiBundle}" as="script" />
+52 -8
View File
@@ -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/<name>` 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 `<head>` 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 <head> 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/<name> 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/<name> 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 <head> 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 <head> 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/<name>`, incl. `/desktop/app/<name>`) 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