diff --git a/api/router.ts b/api/router.ts index 997091a..45fc545 100644 --- a/api/router.ts +++ b/api/router.ts @@ -267,6 +267,44 @@ router.get('/_config/media/sync', async (ctx) => { * flight, and this one walks the whole tree reading manifests. Answering both from one route would * make every tick of a progress bar re-read the library. */ +/** + * The three bits Root location needs BEFORE it paints: is it set up? + * + * ── Why this is not assembled from the children ── + * + * Each part of that block fetches its own status, which is right — but it means the block cannot know + * whether it is finished until its children have mounted and answered, so it must render open and then + * fold itself away. That flicker is on every page load, and it is not a rendering problem to be tuned + * away: the information genuinely was not there yet. + * + * One request that answers it first removes the cause. The children still own their own detail and + * still report changes upward while the block is open — this only seeds the opening state so the first + * paint is already correct. + * + * It walks the media tree, which is the expensive part, and is therefore deliberately NOT polled. + */ +router.get('/_config/summary', async (ctx) => { + const user = ctx.get('user'); + const settings = await readSettings(user.email); + + const [website, media] = await Promise.all([ + readWebsiteStatus(user.email), + readMediaStatus(user.email), + ]); + + // Only walked when there is somewhere to walk. Reconciling against a media folder that does not + // exist is a guaranteed empty answer bought at the price of a tree walk. + const outstanding = media.complete + ? await reconcile(user.email).then((state) => state.missing.length + state.stale.length) + : null; + + return ctx.json({ + rootSet: settings.isSet, + websiteReady: website.isRepo, + mediaReady: media.complete && outstanding === 0, + }); +}); + router.get('/_config/media/ledger', async (ctx) => { const user = ctx.get('user'); const scope = ctx.req.query('path') ?? ''; diff --git a/web/RootLocationBlock.tsx b/web/RootLocationBlock.tsx index 2ce739b..04e702d 100644 --- a/web/RootLocationBlock.tsx +++ b/web/RootLocationBlock.tsx @@ -75,6 +75,32 @@ export const RootLocationBlock = () => { const [mediaReady, setMediaReady] = useState(false); const settled = Boolean(saved?.isSet) && websiteReady && mediaReady && !editing; + /* + The same three bits, answered in ONE request before anything renders. + + Without it the block cannot know whether it is finished until its children have mounted and + reported — so it paints open and then folds away, on every single load. That flicker is not a + rendering problem to tune; the information genuinely was not there yet. + + So this seeds the opening state, and the callbacks above keep it live once the block is open. Two + sources for one fact, with a clear division: the summary is what is true on ARRIVAL, the callbacks + are what changes while somebody is looking. + */ + const [summary, setSummary] = useState<{ rootSet: boolean; websiteReady: boolean; mediaReady: boolean } | null>( + null, + ); + useEffect(() => { + void (async () => { + try { + setSummary(await clientRef.current.get('/fofos-admin/_config/summary')); + } catch { + // A member, or the route failing. Neither is a reason to hold the block hostage — fall back to + // the old behaviour of opening and letting the children answer. + setSummary({ rootSet: false, websiteReady: false, mediaReady: false }); + } + })(); + }, []); + /* Shut on the first arrival at a settled block, and open the moment anything needs attention. @@ -83,11 +109,18 @@ export const RootLocationBlock = () => { plain `useState(true)` would slam it shut on every re-render that touched the dependency. */ const [collapsed, setCollapsed] = useState(null); + + // Seeded from the summary, so the FIRST paint is already right. `null` stays the sentinel for + // "nobody has decided", which is what lets a deliberate expand survive everything below. + useEffect(() => { + if (summary) setCollapsed((chosen) => (chosen === null ? summary.mediaReady && summary.websiteReady && summary.rootSet : chosen)); + }, [summary]); + useEffect(() => { setCollapsed((chosen) => { - // Not settled yet: force it open if somebody had shut it, but LEAVE `null` alone. Writing false - // here was a bug — `settled` is false on the first render, so the sentinel was spent before it - // could mean anything and the block never folded itself away. + // Not settled: force it open if it was shut, but LEAVE `null` alone. Writing false here was a + // bug — `settled` is false on the first render, so the sentinel was spent before it could mean + // anything and the block never folded itself away. if (!settled) return chosen === null ? null : false; return chosen === null ? true : chosen; }); @@ -128,6 +161,16 @@ export const RootLocationBlock = () => { } }; + /* + Settled, according to whichever source has spoken. + + The summary answers on arrival; the children answer from then on. Taking the summary while nothing + has reported yet, and the live value once anything has, is what makes the first paint correct + without freezing the block on a snapshot. + */ + const reported = websiteReady || mediaReady; + const settledNow = editing ? false : reported ? settled : Boolean(summary?.rootSet && summary.websiteReady && summary.mediaReady); + const shell = (children: React.ReactNode, status?: React.ReactNode) => ( { status={status} // Only offered once there is nothing to do. A block you can shut on unfinished setup is a way to // hide the thing you came here to fix. - collapsible={settled} - collapsed={settled && collapsed !== false} + // `settledNow` rather than `settled`: between the summary arriving and the children reporting, + // no child has answered yet, and using the live value would open the block for exactly that gap. + collapsible={settledNow} + collapsed={settledNow && collapsed !== false} onCollapsedChange={setCollapsed} > {children} @@ -148,7 +193,9 @@ export const RootLocationBlock = () => { return shell(

Only the owner can change where the site lives.

); } - if (!saved) { + // Both, not either. Rendering on the path alone puts the block on screen before it knows whether it + // is finished, which is the flicker this was all for. + if (!saved || !summary) { return shell(

Loading…

); } @@ -239,7 +286,7 @@ export const RootLocationBlock = () => { Saved - ) : settled ? ( + ) : settledNow ? ( // Big, and the only thing in the row. Everything under here is in order, and a shut block whose // whole message is "nothing to do" should say it in one glance rather than in a sentence.