fix(examples): el enlace directo a un ejemplo pro ya no parpadea un 404

Reportado en /example/pi5-opencv-vision: al recargar aparecia un instante
el 404 con el header completo (menus incluidos) y despues cargaba el
editor. La carrera: los ejemplos pro se registran cuando aterriza el
import dinamico del overlay, y la pagina resolvia la galeria ANTES,
concluia "no existe", pintaba el 404 y al llegar el registro re-renderizaba
al editor.

"No esta en la galeria" y "no esta TODAVIA" son respuestas distintas, asi
que el registro gana una senal de asentado: main.tsx la activa cuando el
import del overlay resuelve (o falla — finally), e inmediatamente en la
build OSS, donde no viene overlay y un 404 debe ser instantaneo. La pagina
se queda en "Loading example..." hasta que el registro asienta y solo
entonces un id ausente es de verdad un 404.

markProExamplesSettled es idempotente y solo notifica una vez, con test —
el sintoma de romper eso seria una tormenta de re-renders por hot-reload
del overlay.
This commit is contained in:
David Montero Crespo 2026-07-30 17:06:05 +02:00
parent 03652c0748
commit 3cc1510cae
4 changed files with 79 additions and 5 deletions

View File

@ -0,0 +1,31 @@
/**
* The "settled" signal for late-registered pro examples.
*
* A direct link to a pro example races the overlay's dynamic import; the
* page must be able to tell "not in the gallery" from "not YET" or it
* flashes a 404 before the editor loads (as reported on
* /example/pi5-opencv-vision). main.tsx flips the flag once the overlay
* import settles or immediately when no overlay is configured.
*/
import { describe, it, expect } from 'vitest';
import {
areProExamplesSettled,
markProExamplesSettled,
subscribeProExamples,
} from '../data/examples';
describe('pro examples settled flag', () => {
it('starts unsettled, settles once, notifies subscribers exactly once', () => {
expect(areProExamplesSettled()).toBe(false);
let ticks = 0;
const unsub = subscribeProExamples(() => ticks++);
markProExamplesSettled();
expect(areProExamplesSettled()).toBe(true);
expect(ticks).toBe(1);
// Idempotent: settling again must not re-notify (a re-render storm
// on every overlay hot-reload would be the symptom).
markProExamplesSettled();
expect(ticks).toBe(1);
unsub();
});
});

View File

@ -11395,6 +11395,30 @@ export function subscribeProExamples(cb: () => void): () => void {
return () => proExamplesListeners.delete(cb);
}
/**
* Has the pro overlay had its chance to register examples?
*
* A direct link to a PRO example (/example/pi5-opencv-vision) races the
* overlay's dynamic import: the page resolves the gallery before
* registerProExamples has run, concluded "not found", and flashed a 404
* full marketing header included before re-rendering into the editor.
* The page needs to distinguish "not in the gallery" from "not in the
* gallery YET". main.tsx flips this: immediately when no overlay is
* configured, otherwise when the overlay's import settles (either way).
*/
let proExamplesSettled = false;
export function markProExamplesSettled(): void {
if (proExamplesSettled) return;
proExamplesSettled = true;
proExamplesVersion++;
for (const l of proExamplesListeners) l();
}
export function areProExamplesSettled(): boolean {
return proExamplesSettled;
}
export function getProExamplesVersion(): number {
return proExamplesVersion;
}

View File

@ -5,6 +5,7 @@ import './index.css';
// useTranslation() always resolves against a live instance. Must come
// before App.
import './i18n';
import { markProExamplesSettled } from './data/examples';
import './components/velxio-components/IC74HC595';
import './components/velxio-components/LogicGateElements';
import './components/velxio-components/TransistorElements';
@ -59,12 +60,17 @@ if (import.meta.env.VITE_PRO_BUILD) {
if (import.meta.env.VITE_DESKTOP) {
import('@pro/desktop_index')
.then((m) => m.mountProDesktop?.())
.catch((err) => console.warn('[pro-desktop] failed to load slim overlay:', err));
.catch((err) => console.warn('[pro-desktop] failed to load slim overlay:', err))
.finally(markProExamplesSettled);
} else {
import('@pro/index')
.then((m) => m.mountPro?.())
.catch((err) => console.warn('[pro] failed to load overlay:', err));
.catch((err) => console.warn('[pro] failed to load overlay:', err))
.finally(markProExamplesSettled);
}
} else {
// No overlay is coming: what the gallery has now is all there will be.
markProExamplesSettled();
}
// Desktop-only hooks (ESP32 QEMU prompt now, welcome screen in Phase 3).

View File

@ -23,7 +23,8 @@
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useParams } from 'react-router-dom';
import { exampleProjects, subscribeProExamples, getProExamplesVersion } from '../data/examples';
import { exampleProjects, subscribeProExamples,
areProExamplesSettled, getProExamplesVersion } from '../data/examples';
import { loadExample, type LibraryInstallProgress } from '../utils/loadExample';
import { EditorPage } from './EditorPage';
import { AppHeader } from '../components/layout/AppHeader';
@ -59,13 +60,25 @@ export const ExampleEditorPage: React.FC = () => {
: `${DOMAIN}/examples`,
});
const settled = useSyncExternalStore(
subscribeProExamples,
areProExamplesSettled,
areProExamplesSettled,
);
useEffect(() => {
if (!exampleId) {
setError(true);
return;
}
if (!example) {
setError(true);
// "Not in the gallery" and "not in the gallery YET" are different
// answers while the pro overlay's dynamic import is still in flight:
// a direct link to a pro example used to flash a 404 (marketing
// header and all) before the overlay registered it and the editor
// took over. Stay on the loading screen until the registry settles;
// only then is a missing id really a 404.
if (settled) setError(true);
return;
}
if (loadedIdRef.current === exampleId) return;
@ -91,7 +104,7 @@ export const ExampleEditorPage: React.FC = () => {
return () => {
cancelled = true;
};
}, [exampleId, example]);
}, [exampleId, example, settled]);
if (error) {
return (