@backmatter/next 0.0.26 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/next",
3
- "version": "0.0.26",
3
+ "version": "0.0.27",
4
4
  "description": "Experimental Next.js adapter for Backmatter. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -50,7 +50,7 @@
50
50
  "@backmatter/core": "^0.0.21",
51
51
  "@mdx-js/mdx": "^3.1.1",
52
52
  "remark-gfm": "^4.0.1",
53
- "@backmatter/studio": "^0.0.4"
53
+ "@backmatter/studio": "^0.0.5"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": ">=16.3.0",
@@ -0,0 +1,39 @@
1
+ // packages/next/src/preview-hold.tsx
2
+ "use client";
3
+
4
+ // A route refresh suspends the preview branch. Its Suspense fallback would
5
+ // otherwise be empty for the whole fetch. The last painted preview is kept
6
+ // here, outside that branch, and shown until the next one is ready.
7
+ import { useEffect, useSyncExternalStore, type ReactNode } from "react";
8
+
9
+ let held: ReactNode = null;
10
+ const listeners = new Set<() => void>();
11
+
12
+ function emit() {
13
+ for (const listener of listeners) listener();
14
+ }
15
+
16
+ function subscribe(listener: () => void) {
17
+ listeners.add(listener);
18
+ return () => listeners.delete(listener);
19
+ }
20
+
21
+ export function clearHeldPreview() {
22
+ if (held === null) return;
23
+ held = null;
24
+ emit();
25
+ }
26
+
27
+ /** Remember the preview that just painted, so a later refresh can keep it. */
28
+ export function HoldPreview({ children }: { children: ReactNode }) {
29
+ useEffect(() => {
30
+ held = children;
31
+ emit();
32
+ }, [children]);
33
+ return children;
34
+ }
35
+
36
+ /** Suspense fallback: the previous preview, or nothing on the first load. */
37
+ export function HeldPreview() {
38
+ return useSyncExternalStore(subscribe, () => held, () => null);
39
+ }
package/src/routes.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // `route` template (including `/` via indexSlug), loads it and renders MDX
5
5
  // or JSON pages. An optional catch-all is required so `/` is a CMS route.
6
6
  import { Fragment, Suspense, createElement, type ReactNode } from "react";
7
+ import { HeldPreview, HoldPreview } from "./preview-hold";
7
8
  import {
8
9
  DEFAULT_TEMPLATE_SLUG,
9
10
  isRoutable,
@@ -425,15 +426,19 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
425
426
  const preview = await resolvePreviewState(cms, options.preview, searchParams);
426
427
  if (!previewReplacesPublished(preview)) return null;
427
428
  return createElement(
428
- Fragment,
429
+ HoldPreview,
429
430
  null,
430
- createElement("style", {
431
- dangerouslySetInnerHTML: {
432
- __html: "[data-backmatter-published]{display:none!important}",
433
- },
434
- }),
435
- docBridge(hit),
436
- await loadPreviewDoc(hit, preview),
431
+ createElement(
432
+ Fragment,
433
+ null,
434
+ createElement("style", {
435
+ dangerouslySetInnerHTML: {
436
+ __html: "[data-backmatter-published]{display:none!important}",
437
+ },
438
+ }),
439
+ docBridge(hit),
440
+ await loadPreviewDoc(hit, preview),
441
+ ),
437
442
  );
438
443
  };
439
444
 
@@ -454,7 +459,7 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
454
459
  Fragment,
455
460
  null,
456
461
  createElement(PublishedRoute, props),
457
- createElement(Suspense, { fallback: null }, createElement(PreviewBranch, props)),
462
+ createElement(Suspense, { fallback: createElement(HeldPreview) }, createElement(PreviewBranch, props)),
458
463
  );
459
464
 
460
465
  const generateMetadata: CatchAllRoute["generateMetadata"] = async ({ params }) => {
@@ -9,6 +9,7 @@ import "@backmatter/studio/studio.css";
9
9
  import studioPkg from "@backmatter/studio/package.json";
10
10
  import { SiteWidget } from "@backmatter/studio/widget";
11
11
  import { markerCookie } from "./gate";
12
+ import { clearHeldPreview } from "./preview-hold";
12
13
  import { subscribeSiteDoc } from "./site-doc-bridge";
13
14
  import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
14
15
 
@@ -19,10 +20,23 @@ const SESSION_RENEW_MS = 30 * 60 * 1000;
19
20
  function useSiteDoc(): SiteDocRef | null {
20
21
  const pathname = usePathname();
21
22
  const [doc, setDoc] = useState<SiteDocRef | null>(null);
23
+ const pathRef = useRef(pathname);
24
+
25
+ useEffect(() => {
26
+ if (pathRef.current === pathname) return;
27
+ pathRef.current = pathname;
28
+ clearHeldPreview();
29
+ setDoc(null);
30
+ }, [pathname]);
22
31
 
23
32
  useEffect(() => {
24
33
  const apply = (next: SiteDocRef | null) => {
25
- setDoc((prev) => (sameSiteDoc(prev, next) ? prev : next));
34
+ setDoc((prev) => {
35
+ // A refresh unmounts the doc marker for a moment. Keep the document
36
+ // the bar already has so the chat stays mounted across that gap.
37
+ if (!next) return prev;
38
+ return sameSiteDoc(prev, next) ? prev : next;
39
+ });
26
40
  };
27
41
  apply(readSiteDoc());
28
42
  const stop = subscribeSiteDoc(apply);