the block knows it is settled before it paints, so it stops flickering

It could not. Each part of Root location fetches its own status, so the block
had no way to know whether it was finished until its children had mounted and
reported — meaning it painted open and then folded away, on every load. That is
not a rendering problem to tune: the information genuinely was not there yet.

So one request answers the three bits first, and nothing renders until both it
and the path have arrived. The children still own their detail and still report
changes upward while the block is open; the summary only seeds the opening
state. Two sources for one fact with a clean division — the summary is what is
true on ARRIVAL, the callbacks are what changes while somebody is looking.

`settledNow` picks between them: the summary while nothing has reported, the
live value once anything has. Using the live value throughout would open the
block for exactly the gap between the summary landing and the children
answering, which is the flicker with extra steps.

The summary walks the media tree, so it is deliberately not polled, and it skips
the walk entirely when there is no media folder to walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 11:55:40 +01:00
co-authored by Claude Opus 5
parent cc283ee7b6
commit e621bc761d
2 changed files with 92 additions and 7 deletions
+38
View File
@@ -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') ?? '';
+54 -7
View File
@@ -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<typeof summary & object>('/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<boolean | null>(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) => (
<Block
icon={FolderRoot}
@@ -136,8 +179,10 @@ export const RootLocationBlock = () => {
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(<p className="text-xs text-muted-foreground">Only the owner can change where the site lives.</p>);
}
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(<p className="text-xs text-muted-foreground">Loading…</p>);
}
@@ -239,7 +286,7 @@ export const RootLocationBlock = () => {
<Check className="size-3.5" />
Saved
</span>
) : 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.
<CircleCheck className="size-6 text-success" />