@pantheon-systems/create-p1-starter-kit 0.12.0 → 0.14.0

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.
Files changed (42) hide show
  1. package/README.md +3 -3
  2. package/lib/cli.js +122 -53
  3. package/lib/cli.test.js +55 -0
  4. package/lib/copy-template.js +37 -0
  5. package/lib/copy-template.test.js +42 -1
  6. package/lib/messages.js +1 -1
  7. package/package.json +7 -4
  8. package/template/README.md +82 -0
  9. package/template/__tests__/auth-route.test.ts +1 -1
  10. package/template/__tests__/chatbot-flag-context.test.ts +55 -0
  11. package/template/__tests__/data-list-block-sub-components.test.tsx +246 -0
  12. package/template/__tests__/data-list-block-utils.test.ts +84 -0
  13. package/template/__tests__/data-list-block.test.ts +198 -0
  14. package/template/__tests__/published-page-routes.test.ts +86 -0
  15. package/template/__tests__/seo-metadata.test.ts +1 -1
  16. package/template/__tests__/styles-canvas-scope.test.ts +1 -1
  17. package/template/__tests__/widget-logout.test.ts +114 -0
  18. package/template/app/[...puckPath]/client.tsx +38 -10
  19. package/template/app/[...puckPath]/page.tsx +10 -117
  20. package/template/app/[...puckPath]/widget-logout.ts +36 -0
  21. package/template/app/p1/(editor)/[[...p1]]/editor-client.tsx +33 -140
  22. package/template/app/page.tsx +11 -69
  23. package/template/app/published-pages.tsx +23 -0
  24. package/template/app/styles.css +1 -1
  25. package/template/app/welcome-block.tsx +25 -0
  26. package/template/ci-examples/github-actions-sync-puck-registry.yml +15 -7
  27. package/template/components/ChatbotFlagProvider.tsx +3 -8
  28. package/template/components/p1-lockup.tsx +2 -1
  29. package/template/components/puck/image-block.tsx +5 -2
  30. package/template/components/puck/welcome-block-render.tsx +30 -37
  31. package/template/components/welcome-block.module.css +124 -0
  32. package/template/eslint.config.js +72 -1
  33. package/template/gitignore +43 -0
  34. package/template/lib/chatbot-flag/ai-generate.ts +1 -1
  35. package/template/lib/chatbot-flag/flag-context.ts +32 -0
  36. package/template/lib/page-seo.ts +1 -1
  37. package/template/package.json +5 -5
  38. package/template/scripts/__tests__/sync-puck-registry.test.ts +3 -3
  39. package/template/scripts/sync-puck-registry.ts +18 -18
  40. package/template/vitest.config.ts +17 -20
  41. package/template/CHANGELOG.md +0 -76
  42. package/template/__tests__/published-page-404.test.ts +0 -65
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { LogoutOutcome } from "@pantheon-systems/puck-css";
3
+ import { runWidgetLogout } from "../app/[...puckPath]/widget-logout";
4
+
5
+ // Records the effects in order, so each test asserts the whole sequence a
6
+ // logout attempt produces rather than one call in isolation.
7
+ function recorder(logout: () => Promise<LogoutOutcome>) {
8
+ const calls: string[] = [];
9
+ return {
10
+ calls,
11
+ fx: {
12
+ logout,
13
+ navigate: (url: string) => calls.push(`navigate:${url}`),
14
+ reload: () => calls.push("reload"),
15
+ setBusy: (busy: boolean) => calls.push(`busy:${busy}`),
16
+ setError: (message: string | null) =>
17
+ calls.push(`error:${message ?? "cleared"}`),
18
+ },
19
+ };
20
+ }
21
+
22
+ const LOGOUT_URL = "https://example.auth0.com/v2/logout?client_id=abc";
23
+
24
+ describe("runWidgetLogout", () => {
25
+ it("navigates to the Auth0 logout URL when the session ended", async () => {
26
+ const { calls, fx } = recorder(async () => ({
27
+ status: "signed_out",
28
+ logoutUrl: LOGOUT_URL,
29
+ }));
30
+
31
+ await runWidgetLogout(fx);
32
+
33
+ // Stays busy: the navigation replaces the page, so releasing the button
34
+ // would only flash it back to "Log out" on the way out.
35
+ expect(calls).toEqual([
36
+ "busy:true",
37
+ "error:cleared",
38
+ `navigate:${LOGOUT_URL}`,
39
+ ]);
40
+ });
41
+
42
+ it("keeps the menu open and shows why when logout failed", async () => {
43
+ const { calls, fx } = recorder(async () => ({
44
+ status: "error",
45
+ message: "Broker logout failed (503)",
46
+ }));
47
+
48
+ await runWidgetLogout(fx);
49
+
50
+ // Still signed in and retryable, so the button must come back.
51
+ expect(calls).toEqual([
52
+ "busy:true",
53
+ "error:cleared",
54
+ "error:Broker logout failed (503)",
55
+ "busy:false",
56
+ ]);
57
+ });
58
+
59
+ it("reloads when there was no session to end", async () => {
60
+ const { calls, fx } = recorder(async () => ({ status: "no_session" }));
61
+
62
+ await runWidgetLogout(fx);
63
+
64
+ expect(calls).toEqual(["busy:true", "error:cleared", "reload"]);
65
+ });
66
+
67
+ it("reports a thrown error instead of leaving the button stuck", async () => {
68
+ const { calls, fx } = recorder(async () => {
69
+ throw new Error("Failed to fetch");
70
+ });
71
+
72
+ await runWidgetLogout(fx);
73
+
74
+ expect(calls).toEqual([
75
+ "busy:true",
76
+ "error:cleared",
77
+ "error:Failed to fetch",
78
+ "busy:false",
79
+ ]);
80
+ });
81
+
82
+ it("falls back to a generic message when something non-Error is thrown", async () => {
83
+ const { calls, fx } = recorder(async () => {
84
+ throw "socket closed";
85
+ });
86
+
87
+ await runWidgetLogout(fx);
88
+
89
+ expect(calls).toEqual([
90
+ "busy:true",
91
+ "error:cleared",
92
+ "error:Logout failed",
93
+ "busy:false",
94
+ ]);
95
+ });
96
+
97
+ it("clears a previous failure before retrying", async () => {
98
+ const { calls, fx } = recorder(async () => ({
99
+ status: "signed_out",
100
+ logoutUrl: LOGOUT_URL,
101
+ }));
102
+
103
+ await runWidgetLogout(fx);
104
+ await runWidgetLogout(fx);
105
+
106
+ // The second attempt clears the slot again; a stale message must not sit
107
+ // under a logout that is now succeeding.
108
+ expect(calls.slice(3)).toEqual([
109
+ "busy:true",
110
+ "error:cleared",
111
+ `navigate:${LOGOUT_URL}`,
112
+ ]);
113
+ });
114
+ });
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { useEffect, useState } from "react";
4
4
  import type { Data } from "@puckeditor/core";
5
- import { RenderClient } from "@pantheon-systems/puck-css";
5
+ import { RenderClient, performLogout, P1_LOGGED_IN_KEY } from "@pantheon-systems/puck-css";
6
6
  import config from "../../puck.config";
7
+ import { runWidgetLogout } from "./widget-logout";
7
8
 
8
9
  function EditIcon() {
9
10
  return (
@@ -35,10 +36,12 @@ function LogoutIcon() {
35
36
  export function P1EditWidget({ route }: { route: string }) {
36
37
  const [hasToken, setHasToken] = useState(false);
37
38
  const [open, setOpen] = useState(false);
39
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
40
+ const [logoutError, setLogoutError] = useState<string | null>(null);
38
41
 
39
42
  useEffect(() => {
40
43
  const flag = typeof localStorage !== "undefined"
41
- ? localStorage.getItem("p1_logged_in")
44
+ ? localStorage.getItem(P1_LOGGED_IN_KEY)
42
45
  : null;
43
46
  setHasToken(!!flag);
44
47
  }, []);
@@ -63,6 +66,22 @@ export function P1EditWidget({ route }: { route: string }) {
63
66
 
64
67
  const editHref = `/p1${route === "/" ? "" : route}`;
65
68
 
69
+ const handleLogout = () =>
70
+ runWidgetLogout({
71
+ logout: () =>
72
+ performLogout({
73
+ cssBaseUrl: process.env.NEXT_PUBLIC_CSS_BASE_URL ?? "http://localhost:8787",
74
+ }),
75
+ navigate: (url) => {
76
+ window.location.href = url;
77
+ },
78
+ reload: () => {
79
+ window.location.reload();
80
+ },
81
+ setBusy: setIsLoggingOut,
82
+ setError: setLogoutError,
83
+ });
84
+
66
85
  return (
67
86
  <div
68
87
  data-p1-widget
@@ -145,12 +164,8 @@ export function P1EditWidget({ route }: { route: string }) {
145
164
  <div style={{ height: 1, background: "#e0e0e0", margin: "6px 4px" }} />
146
165
  <button
147
166
  role="menuitem"
148
- onClick={() => {
149
- localStorage.removeItem("p1_logged_in");
150
- localStorage.removeItem("p1_auth_token");
151
- localStorage.removeItem("css_broker_token");
152
- window.location.reload();
153
- }}
167
+ disabled={isLoggingOut}
168
+ onClick={handleLogout}
154
169
  style={{
155
170
  display: "flex",
156
171
  alignItems: "center",
@@ -163,15 +178,28 @@ export function P1EditWidget({ route }: { route: string }) {
163
178
  textDecoration: "none",
164
179
  background: "transparent",
165
180
  border: "none",
166
- cursor: "pointer",
181
+ cursor: isLoggingOut ? "not-allowed" : "pointer",
167
182
  fontFamily: "inherit",
168
183
  }}
169
184
  onMouseEnter={(e) => { e.currentTarget.style.background = "#f5f5f5"; }}
170
185
  onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
171
186
  >
172
187
  <span style={{ color: "#888", flex: "0 0 auto" }}><LogoutIcon /></span>
173
- Log out
188
+ {isLoggingOut ? "Logging out…" : "Log out"}
174
189
  </button>
190
+ {logoutError && (
191
+ <p
192
+ role="alert"
193
+ style={{
194
+ margin: "2px 10px 6px",
195
+ fontSize: 12,
196
+ lineHeight: 1.4,
197
+ color: "#b3261e",
198
+ }}
199
+ >
200
+ {logoutError}
201
+ </p>
202
+ )}
175
203
  </div>
176
204
  )}
177
205
  </div>
@@ -1,130 +1,23 @@
1
1
  /**
2
2
  * Catch-all route that renders user-facing pages generated by Puck.
3
+ *
4
+ * The pipeline lives in the SDK — see createPublishedPage in
5
+ * app/published-pages.tsx for what this route is made of.
3
6
  */
4
7
 
5
- import { cache } from "react";
6
- import type { Metadata } from "next";
7
- import { notFound } from "next/navigation";
8
- import {
9
- loadRemoteDatasourceContext,
10
- extractReferencedDatasourceIds,
11
- resolveDataTemplates,
12
- pagePathFromCatchAllSegments,
13
- } from "@pantheon-systems/puck-css/server";
14
- import {
15
- createCssQueryFetchers,
16
- loadPublishedPage,
17
- loadRouteTemplateKeys,
18
- } from "@pantheon-systems/p1-next-sdk/server";
19
- import { REMOTE_DATASOURCE_FETCHERS } from "../../lib/remote-datasource-fetchers";
20
- import { ContentUnavailable } from "../../components/content-unavailable";
21
- import { resolvePageMetadata } from "../../lib/page-seo";
22
- import { Client } from "./client";
23
-
24
- const getCssQueryFetchers = cache(() => createCssQueryFetchers());
25
-
26
- // Document namespaces that live alongside pages but are never routable.
27
- const INTERNAL_PATH_PREFIXES = ["/_registry", "/_redirects"];
28
-
29
- // Lowercased to match the server, which normalizes document paths to lower case
30
- // before looking them up — so /_Redirects/x resolves the same record as
31
- // /_redirects/x and must be refused just the same.
32
- function isInternalPath(path: string): boolean {
33
- const normalized = path.toLowerCase();
34
- return INTERNAL_PATH_PREFIXES.some(
35
- (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`),
36
- );
37
- }
8
+ import { published } from "../published-pages";
38
9
 
39
10
  /**
40
11
  * Backstop only: publishing calls revalidatePath for the affected routes, so
41
12
  * cached pages normally refresh the moment their content changes. This bounds
42
13
  * how long an edit made outside that path (a direct API write, a restored
43
14
  * branch) can stay stale.
15
+ *
16
+ * Must stay a literal here: Next.js statically analyzes segment-config exports,
17
+ * so a value re-exported from the factory would go undetected.
44
18
  */
45
19
  export const revalidate = 300;
46
20
 
47
- /**
48
- * Empty on purpose. Routes are authored in P1, so there is nothing to enumerate
49
- * at build time — but declaring this is what marks the segment statically
50
- * renderable at all; without it every path here renders fully dynamically and
51
- * no response is ever cacheable. Unlisted paths render on first request and are
52
- * cached from then on (dynamicParams defaults to true).
53
- */
54
- export function generateStaticParams(): { puckPath: string[] }[] {
55
- return [];
56
- }
57
-
58
- export async function generateMetadata({
59
- params,
60
- }: {
61
- params: Promise<{ puckPath: string[] }>;
62
- }): Promise<Metadata> {
63
- const { puckPath = [] } = await params;
64
- const path = pagePathFromCatchAllSegments(puckPath);
65
-
66
- if (isInternalPath(path)) {
67
- return { title: "Not Found" };
68
- }
69
-
70
- const result = await loadPublishedPage(path);
71
- if (result.status === "missing") return { title: "Not Found" };
72
- if (result.status === "unavailable") {
73
- return { title: "Temporarily unavailable" };
74
- }
75
- return resolvePageMetadata({ pageData: result.data, path });
76
- }
77
-
78
- export default async function Page({
79
- params,
80
- }: {
81
- params: Promise<{ puckPath: string[] }>;
82
- }) {
83
- const { puckPath = [] } = await params;
84
- const path = pagePathFromCatchAllSegments(puckPath);
85
-
86
- if (isInternalPath(path)) {
87
- notFound();
88
- }
89
-
90
- const result = await loadPublishedPage(path);
91
-
92
- // A real 404, so misses are not cached as successes now that this route is
93
- // statically renderable. An outage is deliberately not a 404 — that would
94
- // deindex published pages over a transient blip.
95
- if (result.status === "missing") {
96
- notFound();
97
- }
98
- if (result.status === "unavailable") {
99
- return <ContentUnavailable />;
100
- }
101
-
102
- const data = result.data;
103
-
104
- const [routeTemplateKeys, cssQueryFetchers] = await Promise.all([
105
- loadRouteTemplateKeys(),
106
- getCssQueryFetchers(),
107
- ]);
108
- const builtinFetchers = [...REMOTE_DATASOURCE_FETCHERS, ...cssQueryFetchers];
109
-
110
- const referencedDatasourceIds = extractReferencedDatasourceIds(data);
111
- const context = await loadRemoteDatasourceContext({
112
- fetchImpl: fetch,
113
- pagePath: path,
114
- routeTemplateKeys,
115
- builtinFetchers,
116
- referencedDatasourceIds,
117
- });
118
- const resolvedData = await resolveDataTemplates(data, context);
119
-
120
- return (
121
- <Client
122
- data={resolvedData}
123
- pageMetadata={{
124
- route: path,
125
- documentName: data.root.props?.title as string | undefined,
126
- pageType: "page",
127
- }}
128
- />
129
- );
130
- }
21
+ export const generateStaticParams = published.generateStaticParams;
22
+ export const generateMetadata = published.generateMetadata;
23
+ export default published.Page;
@@ -0,0 +1,36 @@
1
+ import type { LogoutOutcome } from "@pantheon-systems/puck-css";
2
+
3
+ export type WidgetLogoutEffects = {
4
+ logout: () => Promise<LogoutOutcome>;
5
+ navigate: (url: string) => void;
6
+ reload: () => void;
7
+ setBusy: (busy: boolean) => void;
8
+ setError: (message: string | null) => void;
9
+ };
10
+
11
+ /**
12
+ * Runs one logout attempt for the widget. Kept outside the component so each
13
+ * outcome — navigate away, stay and explain, reload — can be exercised without
14
+ * a browser.
15
+ */
16
+ export async function runWidgetLogout(fx: WidgetLogoutEffects): Promise<void> {
17
+ fx.setBusy(true);
18
+ fx.setError(null);
19
+ try {
20
+ const outcome = await fx.logout();
21
+ if (outcome.status === "signed_out") {
22
+ fx.navigate(outcome.logoutUrl);
23
+ return; // navigation takes over
24
+ }
25
+ if (outcome.status === "error") {
26
+ // Still signed in and retryable — keep the menu open and say why.
27
+ fx.setError(outcome.message);
28
+ fx.setBusy(false);
29
+ return;
30
+ }
31
+ fx.reload(); // no_session: drop any stale widget state
32
+ } catch (err) {
33
+ fx.setError(err instanceof Error ? err.message : "Logout failed");
34
+ fx.setBusy(false);
35
+ }
36
+ }
@@ -14,6 +14,7 @@ import {
14
14
  wrapConfigForEditorPreview,
15
15
  P1QueryProvider,
16
16
  editorPathHref,
17
+ EditorReloadOverlay,
17
18
  } from "@pantheon-systems/puck-css";
18
19
  import { DatasourceRegistryProvider, DatasourceDataProvider } from "@pantheon-systems/puck-css/fields";
19
20
  import { LoadingMessage } from "@pantheon-systems/puck-css/pds";
@@ -30,42 +31,12 @@ import "@pantheon-systems/puck-css/pds/styles.css";
30
31
 
31
32
  import { ChatbotFlagProvider } from "../../../../components/ChatbotFlagProvider";
32
33
  import { P1Lockup } from "../../../../components/p1-lockup";
34
+ import styles from "../../../../components/welcome-block.module.css";
33
35
  import config from "../../../../puck.config";
34
36
  import { shouldShowChatbot, CHATBOT_FLAG_KEY } from "../../../../lib/chatbot-flag/feature-gate";
35
37
  import { createGenerateWithAIHandler } from "../../../../lib/chatbot-flag/ai-generate";
36
38
  import { getDraftRequestChannel } from "../../../../lib/chatbot-flag/draft-request-channel";
37
39
 
38
- const DEFAULT_PAGE_DATA = {
39
- root: { props: { title: "New page" } },
40
- content: [],
41
- zones: {},
42
- };
43
-
44
- const DEFAULT_ROOT_PAGE_DATA = {
45
- root: { props: { title: "Welcome | P1 site" } },
46
- content: [
47
- {
48
- type: "P1WelcomeBlock",
49
- props: {
50
- id: "seed-welcome",
51
- heading: "Welcome to your new Pantheon P1 Site.",
52
- description: "You just created this new site from Pantheon P1 starter kit, congrats! You'll need a Pantheon P1 user account to edit it and create new pages.",
53
- ctaLabel: "Sign-in to P1",
54
- ctaHref: "/p1",
55
- footnote: "Visit [P1 documentation](https://docs.pantheon.io) for more information.",
56
- loggedInHeading: "Welcome to your new Pantheon P1 Site.",
57
- loggedInDescription: "You just created this new site from Pantheon P1 starter kit, congrats! Start editing this page or visit the P1 dashboard to manage your site.",
58
- loggedInCtaLabel: "Edit this page with P1 Visual Editor",
59
- loggedInCtaHref: "/p1",
60
- loggedInSecondaryLabel: "Go to P1 Dashboard",
61
- loggedInFootnote: "Visit [P1 documentation](https://docs.pantheon.io) for more information.",
62
- showLogo: true,
63
- },
64
- },
65
- ],
66
- zones: {},
67
- };
68
-
69
40
  let p1Config: ReturnType<typeof createNextConfig> | null = null;
70
41
  let p1ConfigError: string | null = null;
71
42
 
@@ -83,33 +54,31 @@ function P1SignInPage() {
83
54
  const { login, isLoading, error } = useP1Auth();
84
55
 
85
56
  return (
86
- <div className="w-full max-w-[620px] mx-auto flex flex-col items-center text-center px-8 py-16 font-['Inter',system-ui,sans-serif] text-[#1a1a2e] min-h-screen justify-center">
87
- <P1Lockup />
57
+ <div className={styles.surface}>
58
+ <div className={styles.inner}>
59
+ <P1Lockup />
88
60
 
89
- <h1 className="text-[2.5rem] leading-[1.08] font-semibold m-0 mb-3" style={{ fontSize: '2.5rem' }}>
90
- Your Collaborative Website Management Workspace.
91
- </h1>
92
- <p className="text-base leading-6 text-[#5a5a6e] max-w-[54ch] m-0">
93
- Log in to your Pantheon P1 account to edit your P1 powered website.
94
- If you don&apos;t have yet a Pantheon P1 account, contact us{" "}
95
- <a href="https://pantheon.io/contact-us" className="text-blue-600 underline">here</a>.
96
- </p>
61
+ <h1 className={styles.heading}>
62
+ Your Collaborative Website Management Workspace.
63
+ </h1>
64
+ <p className={styles.description}>
65
+ Log in to your Pantheon P1 account to edit your P1 powered website.
66
+ If you don&apos;t have yet a Pantheon P1 account, contact us{" "}
67
+ <a href="https://pantheon.io/contact-us" className={styles.link}>here</a>.
68
+ </p>
97
69
 
98
- <div className="flex gap-3 mt-8 justify-center">
99
- <button
100
- className="inline-flex items-center justify-center h-12 px-6 gap-2 rounded-full border border-[#1a1a2e] bg-[#1a1a2e] text-white font-['Inter',system-ui,sans-serif] text-lg font-medium leading-none whitespace-nowrap cursor-pointer transition-colors duration-200 hover:bg-[#2d2d44] hover:border-[#2d2d44] focus-visible:outline focus-visible:outline-1 focus-visible:outline-blue-600 focus-visible:outline-offset-1 disabled:opacity-40 disabled:cursor-not-allowed"
101
- onClick={() => void login()}
102
- disabled={isLoading}
103
- >
104
- {isLoading ? "Signing in..." : "Continue"}
105
- </button>
106
- </div>
70
+ <div className={styles.actions}>
71
+ <button
72
+ className={styles.button}
73
+ onClick={() => void login()}
74
+ disabled={isLoading}
75
+ >
76
+ {isLoading ? "Signing in..." : "Continue"}
77
+ </button>
78
+ </div>
107
79
 
108
- {error && (
109
- <p className="mt-4 text-sm text-red-600 bg-red-50 px-3 py-2 rounded-md">
110
- {error}
111
- </p>
112
- )}
80
+ {error && <p className={styles.error}>{error}</p>}
81
+ </div>
113
82
  </div>
114
83
  );
115
84
  }
@@ -120,7 +89,6 @@ export function EditorClientWrapper() {
120
89
  const pathname = usePathname();
121
90
  const path = editorPagePathFromUrlPath(pathname);
122
91
  const [userRole, setUserRole] = useState<ContentRole>('editor');
123
- const lastGoodStateRef = React.useRef<{ puckKey: string; puckProps: any } | null>(null);
124
92
 
125
93
  if (!p1Config) {
126
94
  return (
@@ -145,7 +113,7 @@ export function EditorClientWrapper() {
145
113
  loginFallback={<P1SignInPage />}
146
114
  >
147
115
  <ChatbotFlagProvider>
148
- <EditorContent path={path} lastGoodStateRef={lastGoodStateRef} />
116
+ <EditorContent path={path} />
149
117
  </ChatbotFlagProvider>
150
118
  </P1App>
151
119
  {process.env.NEXT_PUBLIC_ENABLE_ROLE_SWITCHER === 'true' && (
@@ -206,15 +174,8 @@ function RoleSwitcher({
206
174
  );
207
175
  }
208
176
 
209
- function EditorContent({
210
- path,
211
- lastGoodStateRef,
212
- }: {
213
- path: string;
214
- lastGoodStateRef: React.MutableRefObject<{ puckKey: string; puckProps: any } | null>;
215
- }) {
177
+ function EditorContent({ path }: { path: string }) {
216
178
  const router = useRouter();
217
- const { getToken } = useP1Auth();
218
179
  const { data: editorCtx } = useEditorContext(path);
219
180
  const {
220
181
  context: remoteDatasourceContext,
@@ -264,27 +225,10 @@ function EditorContent({
264
225
  [router],
265
226
  );
266
227
 
267
- const handleDocumentNotFound = useCallback(
268
- async (docPath: string, _error: Error) => {
269
- const initialData = docPath === "/" ? DEFAULT_ROOT_PAGE_DATA : DEFAULT_PAGE_DATA;
270
- const token = await getToken();
271
- const headers: Record<string, string> = { "Content-Type": "application/json" };
272
- if (token) headers["Authorization"] = `Bearer ${token}`;
273
- const res = await fetch("/p1/api/structure/page", {
274
- method: "POST",
275
- headers,
276
- body: JSON.stringify({ path: docPath, initialData }),
277
- });
278
- return res.ok;
279
- },
280
- [getToken],
281
- );
282
-
283
- const { loading, error, puckKey, puckProps } = useP1Editor({
228
+ const { loading, reloading, hasContent, error, puckKey, puckProps } = useP1Editor({
284
229
  documentPath: path,
285
230
  puckConfig: editorConfig,
286
231
  additionalPlugins,
287
- onDocumentNotFound: handleDocumentNotFound,
288
232
  pluginOptions: {
289
233
  onDocumentSelect: handleDocumentSelect,
290
234
  onGenerateWithAI: createGenerateWithAIHandler(draftRequests, chatbotEnabled),
@@ -305,24 +249,17 @@ function EditorContent({
305
249
  },
306
250
  });
307
251
 
308
- // Update last good state when loading completes successfully (ref passed from parent)
309
- React.useEffect(() => {
310
- if (!loading && !error) {
311
- lastGoodStateRef.current = { puckKey, puckProps };
312
- }
313
- }, [loading, error, puckKey, puckProps]);
314
-
315
252
  if (redirecting) {
316
253
  return <LoadingMessage message="Redirecting" data-testid="editor-redirecting" />;
317
254
  }
318
255
 
319
- // Show full loading screen only on first load (no previous state)
320
- if (loading && !lastGoodStateRef.current) {
256
+ if (loading) {
321
257
  return <LoadingMessage message="Loading document" data-testid="editor-loading" />;
322
258
  }
323
259
 
324
- // Show error only if we have no previous state to fall back to
325
- if (error && !lastGoodStateRef.current) {
260
+ // A failed load with a document already on screen keeps that document; only a
261
+ // failure with nothing to fall back on takes over the view.
262
+ if (error && !hasContent) {
326
263
  return (
327
264
  <div style={{ textAlign: "center", padding: "4rem", fontFamily: "system-ui" }}>
328
265
  <h3>Error loading document</h3>
@@ -331,57 +268,13 @@ function EditorContent({
331
268
  );
332
269
  }
333
270
 
334
- // Use current state if loaded, otherwise keep showing last good state
335
- const displayState = (!loading && !error)
336
- ? { puckKey, puckProps }
337
- : lastGoodStateRef.current ?? { puckKey, puckProps };
338
-
339
271
  return (
340
272
  <div className="puck-editor-theme" style={{ position: "relative" }}>
341
- {/* Loading overlay - shown during branch switch */}
342
- {loading && lastGoodStateRef.current && (
343
- <div
344
- style={{
345
- position: "fixed",
346
- top: "50%",
347
- left: "50%",
348
- transform: "translate(-50%, -50%)",
349
- zIndex: 9999,
350
- background: "rgba(255, 255, 255, 0.95)",
351
- padding: "1rem 2rem",
352
- borderRadius: "8px",
353
- boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
354
- fontFamily: "system-ui",
355
- fontSize: "14px",
356
- color: "#333",
357
- fontWeight: 500,
358
- display: "flex",
359
- alignItems: "center",
360
- gap: "0.75rem",
361
- }}
362
- >
363
- <div
364
- style={{
365
- width: "16px",
366
- height: "16px",
367
- border: "2px solid #e0e0e0",
368
- borderTopColor: "#2563eb",
369
- borderRadius: "50%",
370
- animation: "spin 0.6s linear infinite",
371
- }}
372
- />
373
- Switching workstream...
374
- <style>{`
375
- @keyframes spin {
376
- to { transform: rotate(360deg); }
377
- }
378
- `}</style>
379
- </div>
380
- )}
273
+ <EditorReloadOverlay reloading={reloading} />
381
274
  <DatasourceRegistryProvider registry={editorCtx?.remoteDatasourceRegistry ?? []}>
382
275
  <DatasourceDataProvider context={remoteDatasourceContext}>
383
276
  {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
384
- <Puck key={`${displayState.puckKey}-${chatbotEnabled ? "ai" : "no-ai"}`} {...displayState.puckProps as any} _experimentalFullScreenCanvas={true} />
277
+ <Puck key={`${puckKey}-${chatbotEnabled ? "ai" : "no-ai"}`} {...puckProps as any} _experimentalFullScreenCanvas={true} />
385
278
  </DatasourceDataProvider>
386
279
  </DatasourceRegistryProvider>
387
280
  </div>