Gate authenticated homepage rendering until the public Hop home swap completes, with failure fallback and regression tests
Hop-State: A_06FNBF34SYZE98C5FTCSNQ8 Hop-Proposal: R_06FNBF2AQPTF8B2YVMQ8NJG Hop-Task: T_06FNBEGE901NTR8R2RAPC70 Hop-Attempt: AT_06FNBEGE90FVP4AP4KQ3E40
This commit is contained in:
@@ -47,6 +47,9 @@ docker compose --env-file "$env_file" cp \
|
|||||||
docker compose --env-file "$env_file" cp \
|
docker compose --env-file "$env_file" cp \
|
||||||
deploy/gitea/public/assets/js/hop-native.js \
|
deploy/gitea/public/assets/js/hop-native.js \
|
||||||
gitea:/data/gitea/public/assets/js/hop-native-v17.js
|
gitea:/data/gitea/public/assets/js/hop-native-v17.js
|
||||||
|
docker compose --env-file "$env_file" cp \
|
||||||
|
deploy/gitea/public/assets/js/hop-native.js \
|
||||||
|
gitea:/data/gitea/public/assets/js/hop-native-v18.js
|
||||||
docker compose --env-file "$env_file" cp \
|
docker compose --env-file "$env_file" cp \
|
||||||
deploy/gitea/public/assets/css/hop-home.css \
|
deploy/gitea/public/assets/css/hop-home.css \
|
||||||
gitea:/data/gitea/public/assets/css/hop-home.css
|
gitea:/data/gitea/public/assets/css/hop-home.css
|
||||||
|
|||||||
@@ -345,15 +345,37 @@
|
|||||||
return new URLSearchParams(window.location.search).get('hop') === 'home';
|
return new URLSearchParams(window.location.search).get('hop') === 'home';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signedIn() {
|
||||||
|
return Boolean(document.querySelector('meta[name="hop-signed-user-id"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealHopPage() {
|
||||||
|
document.documentElement.dataset.hopNative = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPublicHomepage() {
|
async function loadPublicHomepage() {
|
||||||
const link = document.querySelector('[data-hop-public-home-link]');
|
const link = document.querySelector('[data-hop-public-home-link]');
|
||||||
if (link) link.classList.toggle('active', publicHomepageRequested());
|
if (link) link.classList.toggle('active', publicHomepageRequested());
|
||||||
if (!link || !publicHomepageRequested() || document.documentElement.dataset.hopPublicHome) return;
|
if (!publicHomepageRequested()) return;
|
||||||
|
|
||||||
|
if (document.querySelector('#hop-main')) {
|
||||||
|
document.documentElement.dataset.hopPublicHome = 'rendered';
|
||||||
|
revealHopPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signedIn()) {
|
||||||
|
revealHopPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.documentElement.dataset.hopPublicHome) return;
|
||||||
|
|
||||||
document.documentElement.dataset.hopPublicHome = 'loading';
|
document.documentElement.dataset.hopPublicHome = 'loading';
|
||||||
try {
|
try {
|
||||||
const publicURL = new URL(link.href, window.location.origin);
|
const publicURL = new URL(link?.href || window.location.href, window.location.origin);
|
||||||
publicURL.search = '';
|
publicURL.search = '';
|
||||||
|
publicURL.hash = '';
|
||||||
const response = await fetch(publicURL, {credentials: 'omit'});
|
const response = await fetch(publicURL, {credentials: 'omit'});
|
||||||
if (!response.ok) throw new Error(`Could not load the Hop homepage (${response.status})`);
|
if (!response.ok) throw new Error(`Could not load the Hop homepage (${response.status})`);
|
||||||
|
|
||||||
@@ -372,8 +394,10 @@
|
|||||||
bindInstallationSwitchers();
|
bindInstallationSwitchers();
|
||||||
bindInstallationCopyButtons();
|
bindInstallationCopyButtons();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
delete document.documentElement.dataset.hopPublicHome;
|
document.documentElement.dataset.hopPublicHome = 'failed';
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
revealHopPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +413,12 @@
|
|||||||
bindInstallationSwitchers();
|
bindInstallationSwitchers();
|
||||||
bindInstallationCopyButtons();
|
bindInstallationCopyButtons();
|
||||||
void loadPublicHomepage();
|
void loadPublicHomepage();
|
||||||
document.documentElement.dataset.hopNative = 'true';
|
const publicHomepageNeedsSwap = publicHomepageRequested()
|
||||||
|
&& signedIn()
|
||||||
|
&& !document.querySelector('#hop-main');
|
||||||
|
if (!publicHomepageNeedsSwap || document.documentElement.dataset.hopPublicHome === 'failed') {
|
||||||
|
revealHopPage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let queued = false;
|
let queued = false;
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFile} from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const source = await readFile(new URL('./hop-native.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
function page({search = '', signedIn = false, fetchResult} = {}) {
|
||||||
|
const dataset = {};
|
||||||
|
const publicHomeLink = {
|
||||||
|
classList: {toggle() {}},
|
||||||
|
href: 'https://hop.test/',
|
||||||
|
};
|
||||||
|
let currentMainReplaced = false;
|
||||||
|
const currentMain = {
|
||||||
|
replaceWith() {
|
||||||
|
currentMainReplaced = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const publicMain = {
|
||||||
|
parentElement: {querySelectorAll: () => []},
|
||||||
|
};
|
||||||
|
const document = {
|
||||||
|
documentElement: {dataset},
|
||||||
|
title: 'Dashboard',
|
||||||
|
addEventListener() {},
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === 'meta[name="hop-signed-user-id"]') return signedIn ? {} : null;
|
||||||
|
if (selector === '[data-hop-public-home-link]') return signedIn ? publicHomeLink : null;
|
||||||
|
if (selector === '[role="main"]') return currentMain;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
querySelectorAll() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
console: {error() {}},
|
||||||
|
document,
|
||||||
|
DOMParser: class {
|
||||||
|
parseFromString() {
|
||||||
|
return {
|
||||||
|
title: 'Hop',
|
||||||
|
querySelector: (selector) => selector === '#hop-main' ? publicMain : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetch: fetchResult || (() => Promise.resolve({ok: true, text: () => Promise.resolve('<main id="hop-main"></main>')})),
|
||||||
|
MutationObserver: class { observe() {} },
|
||||||
|
URL,
|
||||||
|
URLSearchParams,
|
||||||
|
window: {
|
||||||
|
location: {href: `https://hop.test/${search}`, origin: 'https://hop.test', pathname: '/', search},
|
||||||
|
requestAnimationFrame() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vm.runInNewContext(source, context);
|
||||||
|
return {dataset, get currentMainReplaced() { return currentMainReplaced; }};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps the authenticated dashboard hidden until the public homepage is ready', async () => {
|
||||||
|
let finishFetch;
|
||||||
|
const fetchResult = () => new Promise((resolve) => { finishFetch = resolve; });
|
||||||
|
const result = page({search: '?hop=home', signedIn: true, fetchResult});
|
||||||
|
|
||||||
|
assert.equal(result.dataset.hopPublicHome, 'loading');
|
||||||
|
assert.equal(result.dataset.hopNative, undefined);
|
||||||
|
assert.equal(result.currentMainReplaced, false);
|
||||||
|
|
||||||
|
finishFetch({ok: true, text: () => Promise.resolve('<main id="hop-main"></main>')});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
assert.equal(result.dataset.hopPublicHome, 'rendered');
|
||||||
|
assert.equal(result.dataset.hopNative, 'true');
|
||||||
|
assert.equal(result.currentMainReplaced, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reveals the existing page when the public homepage request fails', async () => {
|
||||||
|
const result = page({
|
||||||
|
search: '?hop=home',
|
||||||
|
signedIn: true,
|
||||||
|
fetchResult: () => Promise.reject(new Error('offline')),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.dataset.hopNative, undefined);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
assert.equal(result.dataset.hopPublicHome, 'failed');
|
||||||
|
assert.equal(result.dataset.hopNative, 'true');
|
||||||
|
assert.equal(result.currentMainReplaced, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reveals ordinary pages immediately', () => {
|
||||||
|
const result = page({signedIn: true});
|
||||||
|
assert.equal(result.dataset.hopNative, 'true');
|
||||||
|
});
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/hop-home.css?v=4">
|
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/hop-home.css?v=4">
|
||||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/hop-prompts.css?v=4">
|
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/hop-prompts.css?v=4">
|
||||||
<script src="{{AssetUrlPrefix}}/js/hop-native-v17.js?v=1"></script>
|
<script src="{{AssetUrlPrefix}}/js/hop-native-v18.js?v=1"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user