diff --git a/runner/apps/authoring/src/App.tsx b/runner/apps/authoring/src/App.tsx
index 080c2065..92ac0fe6 100644
--- a/runner/apps/authoring/src/App.tsx
+++ b/runner/apps/authoring/src/App.tsx
@@ -3,6 +3,7 @@ import { EditorShell, theme, logoUrl, type PreviewStatus } from "@handsontable/d
import {
applyHandsontableCss,
applyHandsontableVersion,
+ deriveDocsBucketCandidate,
validateHandsontableVersion,
type CatalogEntry,
type DemoRuntime,
@@ -12,7 +13,12 @@ import { SandpackRuntime } from "@handsontable/demo-runtime/sandpack";
import { ContainerRuntime } from "@handsontable/demo-runtime/container";
import { zipSync, strToU8 } from "fflate";
import { catalog, getEntry, fetchVersions, VERSION_OPTIONS, DEFAULT_VERSION } from "./catalog.js";
-import { fetchDocsManifest, loadDocsExample, type DocsManifestItem } from "./docs-catalog.js";
+import {
+ fetchDocsManifest,
+ loadDocsExample,
+ type DocsManifest,
+ type DocsManifestItem,
+} from "./docs-catalog.js";
import { DocsCascader, type CascaderLeaf } from "./DocsCascader.js";
import { currentUser, login, logout, getToken, type User } from "./auth.js";
import { MyDemos } from "./MyDemos.js";
@@ -55,6 +61,23 @@ function describeRuntimeError(e: unknown, engine: string): string {
return msg;
}
+function pinHandsontableFiles(files: FilesMap, version: string): FilesMap {
+ const validated = validateHandsontableVersion(version);
+ if (!validated.ok || files["/package.json"] === undefined) return files;
+ try {
+ return applyHandsontableCss(
+ applyHandsontableVersion(files, validated.value),
+ validated.value,
+ );
+ } catch {
+ return files;
+ }
+}
+
+function isMissingDocsResource(error: unknown): boolean {
+ return error instanceof Error && /\b404\b/.test(error.message);
+}
+
type EditorRoute =
| { mode: "play" }
| { mode: "edit"; id: string }
@@ -116,15 +139,17 @@ function Splash({ text }: { text: string }) {
);
}
-function NotFound({ path }: { path: string | null }) {
+function NotFound({ path, transient = false }: { path: string | null; transient?: boolean }) {
return (
- Example not found
+ {transient ? "Example temporarily unavailable" : "Example not found"}
- This example may not be imported yet.
+ {transient
+ ? "The documentation catalog could not be loaded. Try again later."
+ : "This example may not be imported yet."}
{path && (
{path}
@@ -155,16 +180,21 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
const [entry, setEntry] = useState(() => getEntry(framework));
// Non-null when the current example is a documentation-guide example.
const [docsPath, setDocsPath] = useState(initialDocs);
- // Docs examples for the Cascader picker (fetched once).
+ // Docs examples for the currently-resolved version bucket.
const [docsItems, setDocsItems] = useState([]);
+ const [activeDocsBucket, setActiveDocsBucket] = useState(null);
+ const [activeDocsManifest, setActiveDocsManifest] = useState(null);
const [files, setFiles] = useState(() => ({ ...entry.files }));
const [version, setVersion] = useState(
() => new URLSearchParams(location.search).get("v") || DEFAULT_VERSION,
);
const [versionOptions, setVersionOptions] = useState(VERSION_OPTIONS);
+ const [nextVersion, setNextVersion] = useState("");
+ const [versionsResolved, setVersionsResolved] = useState(false);
const [status, setStatus] = useState("booting");
const [errorMessage, setErrorMessage] = useState(null);
+ const [versionWarning, setVersionWarning] = useState(null);
const [bootLog, setBootLog] = useState("");
const [dirty, setDirty] = useState(false);
const [syncing, setSyncing] = useState(false); // container rebuild in flight
@@ -183,6 +213,8 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
// `?docs=` example has resolved, so we don't briefly boot the starter first.
const [sourceLoaded, setSourceLoaded] = useState(!savedId && !initialDocs);
const [docsNotFound, setDocsNotFound] = useState(false);
+ const [docsNotFoundTransient, setDocsNotFoundTransient] = useState(false);
+ const [docsRuntimeBlocked, setDocsRuntimeBlocked] = useState(!!initialDocs);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
@@ -192,6 +224,17 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
const [linksId, setLinksId] = useState(null);
const [forkedFrom, setForkedFrom] = useState(`catalog:${framework}`);
const [myDemosOpen, setMyDemosOpen] = useState(false);
+ const docsPathRef = useRef(docsPath);
+ const dirtyRef = useRef(dirty);
+ const sourceLoadedRef = useRef(sourceLoaded);
+ const activeDocsBucketRef = useRef(activeDocsBucket);
+ const activeDocsManifestRef = useRef(activeDocsManifest);
+ const docsRequestSeqRef = useRef(0);
+ docsPathRef.current = docsPath;
+ dirtyRef.current = dirty;
+ sourceLoadedRef.current = sourceLoaded;
+ activeDocsBucketRef.current = activeDocsBucket;
+ activeDocsManifestRef.current = activeDocsManifest;
/** Replace the whole workspace (entry + files + lineage) and remount. */
const loadWorkspace = useCallback((nextEntry: CatalogEntry, nextFiles: FilesMap, lineage: string) => {
@@ -201,6 +244,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
setFiles(nextFiles);
setForkedFrom(lineage);
setDirty(false);
+ dirtyRef.current = false;
setErrorMessage(null);
setMountGen((g) => g + 1);
}, []);
@@ -244,35 +288,6 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
return () => { cancelled = true; };
}, [savedId, isShare, loadWorkspace]);
- // Fetch the docs-example manifest once (drives the breadcrumb-grouped picker).
- useEffect(() => {
- if (route.mode === "share") return; // read-only shared view has no picker
- let cancelled = false;
- fetchDocsManifest()
- .then((m) => { if (!cancelled) setDocsItems(m.examples); })
- .catch(() => { /* picker just shows starters */ });
- return () => { cancelled = true; };
- }, [route.mode]);
-
- // Play mode with `?docs=`: lazy-load the docs example and boot it.
- useEffect(() => {
- if (!initialDocs) return;
- let cancelled = false;
- loadDocsExample(initialDocs)
- .then((e) => {
- if (cancelled) return;
- loadWorkspace(e, { ...e.files }, `docs:${initialDocs}`);
- setDocsPath(initialDocs);
- setSourceLoaded(true);
- })
- .catch(() => {
- if (cancelled) return;
- setDocsNotFound(true);
- setSourceLoaded(true);
- });
- return () => { cancelled = true; };
- }, [initialDocs, loadWorkspace]);
-
// Load real published versions from the API (npm-backed); default to latest
// unless a version was deep-linked (or pinned by the demo being edited/shared).
useEffect(() => {
@@ -280,38 +295,124 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
fetchVersions(API_BASE)
.then(({ latest, next, versions }) => {
if (cancelled) return;
+ setNextVersion(next ?? "");
const opts = [...new Set([latest, ...versions, next].filter((v): v is string => !!v))];
if (opts.length) setVersionOptions(opts);
if (latest && !hadUrlVersion.current) {
setVersion((cur) => (cur === DEFAULT_VERSION ? latest : cur));
}
+ setVersionsResolved(true);
})
- .catch(() => { /* keep fallback options */ });
+ .catch(() => {
+ if (!cancelled) setVersionsResolved(true); // release buckets can still resolve without dist-tags.next
+ });
return () => { cancelled = true; };
}, []);
- // Reflect the selected version in the editor's package.json (re-pin
- // handsontable + wrapper), keeping any other edits.
+ // A manifest fetch is the existence check for a derived bucket. Resolve it on
+ // startup and every selected-version change, then swap or preserve an open
+ // docs workspace according to its dirty state.
useEffect(() => {
- const v = validateHandsontableVersion(version);
- if (!v.ok) return;
- setFiles((prev) => {
- if (prev["/package.json"] === undefined) return prev;
- let next: FilesMap;
- try {
- next = applyHandsontableCss(applyHandsontableVersion(prev, v.value), v.value);
- } catch {
- return prev;
+ if (route.mode === "share" || !versionsResolved) return;
+ const requestSeq = ++docsRequestSeqRef.current;
+ const openPath = docsPathRef.current;
+ const initialLoad = !!initialDocs && !sourceLoadedRef.current;
+ const candidate = deriveDocsBucketCandidate(version, nextVersion);
+
+ setDocsItems([]);
+ setActiveDocsBucket(null);
+ setActiveDocsManifest(null);
+ activeDocsBucketRef.current = null;
+ activeDocsManifestRef.current = null;
+ if (openPath) {
+ setDocsRuntimeBlocked(true);
+ setStatus("booting");
+ setErrorMessage(null);
+ }
+
+ const failOpenDocs = (kind: "bucket" | "path" | "fetch") => {
+ if (docsRequestSeqRef.current !== requestSeq) return;
+ if (initialLoad) {
+ setDocsNotFoundTransient(kind === "fetch");
+ setDocsNotFound(true);
+ setSourceLoaded(true);
+ sourceLoadedRef.current = true;
+ return;
}
- if (
- next["/package.json"] === prev["/package.json"] &&
- next["/index.html"] === prev["/index.html"] &&
- next["/src/index.html"] === prev["/src/index.html"]
- ) return prev; // no change
- filesRef.current = next;
- return next;
- });
- }, [version, framework, mountGen]);
+ if (!openPath) return;
+ const message = kind === "bucket"
+ ? `No documentation examples are available for Handsontable ${version}. Choose another version or a starter.`
+ : kind === "path"
+ ? `This documentation example is unavailable for Handsontable ${version}. Choose another version or a starter.`
+ : `Could not load documentation examples for Handsontable ${version}. Try another version.`;
+ setStatus("error");
+ setErrorMessage(message);
+ setDocsRuntimeBlocked(true);
+ };
+
+ if (!candidate) {
+ failOpenDocs("bucket");
+ return;
+ }
+
+ let cancelled = false;
+ void fetchDocsManifest(candidate)
+ .then(async (manifest) => {
+ if (cancelled || docsRequestSeqRef.current !== requestSeq) return;
+ setDocsItems(manifest.examples);
+ setActiveDocsBucket(candidate);
+ setActiveDocsManifest(manifest);
+ activeDocsBucketRef.current = candidate;
+ activeDocsManifestRef.current = manifest;
+ if (!openPath) return;
+ if (!manifest.examples.some((item) => item.docsPath === openPath)) {
+ failOpenDocs("path");
+ return;
+ }
+
+ if (dirtyRef.current) {
+ const pinned = pinHandsontableFiles(filesRef.current, version);
+ filesRef.current = pinned;
+ setFiles(pinned);
+ setVersionWarning(
+ "This example has unsaved edits; its content may not match the selected version API.",
+ );
+ setErrorMessage(null);
+ setDocsRuntimeBlocked(false);
+ return;
+ }
+
+ try {
+ const docsEntry = await loadDocsExample(candidate, openPath);
+ if (cancelled || docsRequestSeqRef.current !== requestSeq) return;
+ const nextFiles = manifest.hotVersion === version
+ ? { ...docsEntry.files }
+ : pinHandsontableFiles({ ...docsEntry.files }, version);
+ loadWorkspace(docsEntry, nextFiles, `docs:${candidate}:${openPath}`);
+ setDocsPath(openPath);
+ docsPathRef.current = openPath;
+ setVersionWarning(null);
+ setDocsRuntimeBlocked(false);
+ setSourceLoaded(true);
+ sourceLoadedRef.current = true;
+ } catch (error) {
+ failOpenDocs(isMissingDocsResource(error) ? "path" : "fetch");
+ }
+ })
+ .catch((error) => {
+ failOpenDocs(isMissingDocsResource(error) ? "bucket" : "fetch");
+ });
+ return () => { cancelled = true; };
+ }, [initialDocs, loadWorkspace, nextVersion, route.mode, version, versionsResolved]);
+
+ // Starters never load docs artifacts. Version changes only re-pin their
+ // existing package/CSS files and preserve any edits.
+ useEffect(() => {
+ if (docsPath) return;
+ const pinned = pinHandsontableFiles(filesRef.current, version);
+ filesRef.current = pinned;
+ setFiles(pinned);
+ }, [docsPath, version]);
// Keep the URL in sync with the selected example + version — playground only
// (edit/share have their own /edit/:id, /share/:id paths). Docs examples use
@@ -333,30 +434,82 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
/** Pick a catalog starter template as a fresh starting template (playground). */
const selectExample = useCallback(
(fw: string) => {
+ docsRequestSeqRef.current += 1;
setDocsPath(null);
- loadWorkspace(getEntry(fw), { ...getEntry(fw).files }, `catalog:${fw}`);
+ docsPathRef.current = null;
+ setDocsRuntimeBlocked(false);
+ setVersionWarning(null);
+ const starter = getEntry(fw);
+ loadWorkspace(starter, pinHandsontableFiles({ ...starter.files }, version), `catalog:${fw}`);
},
- [loadWorkspace],
+ [loadWorkspace, version],
);
/** Open a documentation-guide example (lazy-loaded by its docs content path). */
const selectDocs = useCallback(
async (dp: string) => {
+ const bucket = activeDocsBucketRef.current;
+ const manifest = activeDocsManifestRef.current;
+ if (!bucket || !manifest || !manifest.examples.some((item) => item.docsPath === dp)) {
+ setErrorMessage(`Could not load docs example: ${dp}`);
+ return;
+ }
+ const requestSeq = ++docsRequestSeqRef.current;
+ setDocsRuntimeBlocked(true);
+ setStatus("booting");
+ setErrorMessage(null);
try {
- const e = await loadDocsExample(dp);
+ const e = await loadDocsExample(bucket, dp);
+ if (docsRequestSeqRef.current !== requestSeq || activeDocsBucketRef.current !== bucket) return;
+ const nextFiles = manifest.hotVersion === version
+ ? { ...e.files }
+ : pinHandsontableFiles({ ...e.files }, version);
setDocsPath(dp);
- loadWorkspace(e, { ...e.files }, `docs:${dp}`);
+ docsPathRef.current = dp;
+ loadWorkspace(e, nextFiles, `docs:${bucket}:${dp}`);
+ setVersionWarning(null);
+ setDocsRuntimeBlocked(false);
} catch {
// Unlike the deep-link (`?docs=`) load path, a working workspace is already
// open here, so a toolbar note is enough — no full-screen not-found takeover.
- setErrorMessage(`Could not load docs example: ${dp}`);
+ if (docsRequestSeqRef.current === requestSeq) {
+ setStatus("error");
+ setErrorMessage(`Could not load docs example: ${dp}`);
+ setDocsRuntimeBlocked(true);
+ }
}
},
- [loadWorkspace],
+ [loadWorkspace, version],
);
+ const changeVersion = useCallback((next: string) => {
+ docsRequestSeqRef.current += 1;
+ setVersionWarning(null);
+ if (docsPathRef.current) {
+ setDocsItems([]);
+ setActiveDocsBucket(null);
+ setActiveDocsManifest(null);
+ activeDocsBucketRef.current = null;
+ activeDocsManifestRef.current = null;
+ setDocsRuntimeBlocked(true);
+ setStatus("booting");
+ setErrorMessage(null);
+ }
+ setVersion(next);
+ }, []);
+
+ // Dispose and visibly clear a docs preview while its target bucket/path is
+ // unresolved or unavailable. The version picker and editor remain usable.
+ useEffect(() => {
+ if (!iframeEl || !docsRuntimeBlocked) return;
+ runtimeRef.current?.dispose();
+ runtimeRef.current = null;
+ iframeEl.removeAttribute("srcdoc");
+ iframeEl.src = "about:blank";
+ }, [iframeEl, docsRuntimeBlocked]);
+
useEffect(() => {
- if (!iframeEl || !sourceLoaded || docsNotFound) return;
+ if (!iframeEl || !sourceLoaded || docsNotFound || docsRuntimeBlocked) return;
setErrorMessage(null);
const v = validateHandsontableVersion(version);
if (!v.ok) {
@@ -395,11 +548,14 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
if (runtimeRef.current === runtime) runtimeRef.current = null;
};
// mountGen forces a remount when files are replaced (example switch or fork/edit load).
- }, [iframeEl, entry, version, mountGen, sourceLoaded, docsNotFound]);
+ }, [iframeEl, entry, version, mountGen, sourceLoaded, docsNotFound, docsRuntimeBlocked]);
const onEdit = useCallback((path: string, contents: string) => {
- setFiles((prev) => ({ ...prev, [path]: contents }));
+ const next = { ...filesRef.current, [path]: contents };
+ filesRef.current = next;
+ setFiles(next);
setDirty(true);
+ dirtyRef.current = true;
try {
runtimeRef.current?.writeFile(path, contents);
} catch {
@@ -421,6 +577,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
filesRef.current = next;
setFiles(next);
setDirty(true);
+ dirtyRef.current = true;
try { runtimeRef.current?.writeFile(path, ""); } catch { /* not mounted */ }
}, []);
@@ -431,6 +588,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
filesRef.current = next;
setFiles(next);
setDirty(true);
+ dirtyRef.current = true;
try { runtimeRef.current?.deleteFile?.(path); } catch { /* not mounted */ }
}, []);
@@ -442,6 +600,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
filesRef.current = next;
setFiles(next);
setDirty(true);
+ dirtyRef.current = true;
try {
runtimeRef.current?.writeFile(newPath, content);
runtimeRef.current?.deleteFile?.(oldPath);
@@ -549,6 +708,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
throw new Error(body.error || `save failed (${res.status})`);
}
setDirty(false);
+ dirtyRef.current = false;
} catch (e) {
setErrorMessage(e instanceof Error ? e.message : String(e));
} finally {
@@ -568,7 +728,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
.sort((a, b) => FW_PREF.indexOf(a.framework) - FW_PREF.indexOf(b.framework))
: [];
- if (docsNotFound) return ;
+ if (docsNotFound) return ;
if (savedId && !sourceLoaded) return ;
return (
@@ -614,7 +774,9 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
selectedKey={
currentDocsMeta
? `${currentDocsMeta.guide}|${currentDocsMeta.exampleId}`
- : `starter:${framework}`
+ : docsPath
+ ? undefined
+ : `starter:${framework}`
}
onSelect={(leaf: CascaderLeaf) => {
if (leaf.kind === "starter") { selectExample(leaf.framework); return; }
@@ -679,6 +841,11 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
{errorMessage}
)}
+ {versionWarning && (
+
+ {versionWarning}
+
+ )}
{isShare ? (
<>