Skip to content

Commit 2f4decf

Browse files
swisskymarcusbellamyshaw-cell
authored andcommitted
fix(visual-editing): flush unsaved inline edits on pagehide (emdash-cms#1945)
* fix(visual-editing): flush unsaved inline edits on pagehide (emdash-cms#1582) * fix(visual-editing): address review — comment wording, robust e2e persistence check Rename the flush comment's caveat marker and harden the e2e test: wait for the PUT request (Playwright never delivers response events for a page that navigated away, so waitForResponse deadlocks on the keepalive request), then poll the rendered page until the edit is persisted before asserting on a fresh load.
1 parent c4171f9 commit 2f4decf

3 files changed

Lines changed: 104 additions & 35 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes unsaved inline (visual) editor changes being silently lost when navigating away, e.g. via the browser back button. Edits are now flushed with a keepalive request when the page unloads.

e2e/tests/visual-editing.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,49 @@ test.describe("Inline Editor", () => {
297297
await expect(picker).toBeHidden({ timeout: 3000 });
298298
});
299299

300+
test("flushes unsaved edits when navigating away (#1582)", async ({ page }) => {
301+
await gotoWithRetry(page, POST_WITH_IMAGE_PATH);
302+
303+
const editor = page.locator(".emdash-inline-editor");
304+
await expect(editor).toBeVisible({ timeout: 15000 });
305+
306+
// Type into the editor and do NOT blur — the only save trigger
307+
// used to be the blur handler, so browser-back lost the edit.
308+
await editor.locator("p").last().click();
309+
await page.keyboard.press("End");
310+
await page.keyboard.type(" Repro1582");
311+
312+
// waitForRequest, not waitForResponse: the keepalive PUT outlives the
313+
// page, and Playwright never delivers response events for a page that
314+
// has navigated away — waiting for the response times out even when
315+
// the save succeeds.
316+
const savePromise = page.waitForRequest(
317+
(req) => req.url().includes("/api/content/posts/") && req.method() === "PUT",
318+
{ timeout: 10000 },
319+
);
320+
321+
// Browser back: no React blur fires, only pagehide.
322+
await page.goBack();
323+
324+
// The pagehide flush must fire a keepalive PUT…
325+
await savePromise;
326+
327+
// …and the server must persist it. Poll the rendered page instead of
328+
// reloading immediately: on slow runners the PUT may still be in
329+
// flight when the request event fires.
330+
await expect
331+
.poll(async () => (await page.request.get(POST_WITH_IMAGE_PATH)).text(), {
332+
timeout: 10000,
333+
})
334+
.toContain("Repro1582");
335+
336+
// The edit must survive a fresh load of the post.
337+
await gotoWithRetry(page, POST_WITH_IMAGE_PATH);
338+
const reloadedEditor = page.locator(".emdash-inline-editor");
339+
await expect(reloadedEditor).toBeVisible({ timeout: 15000 });
340+
await expect(reloadedEditor.locator("text=Repro1582")).toBeVisible();
341+
});
342+
300343
test("media picker can be closed with X button", async ({ page }) => {
301344
await gotoWithRetry(page, POST_WITH_IMAGE_PATH);
302345

packages/core/src/components/InlinePortableTextEditor.tsx

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1864,44 +1864,65 @@ export function InlinePortableTextEditor({
18641864
return pmToPortableText(json);
18651865
}, []);
18661866

1867-
const save = React.useCallback(async () => {
1868-
if (savingRef.current) return;
1869-
1870-
const current = JSON.stringify(getBlocks());
1871-
const initial = JSON.stringify(initialRef.current);
1872-
if (current === initial) return;
1873-
1874-
savingRef.current = true;
1875-
try {
1876-
const res = await fetch(
1877-
`/_emdash/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(entryId)}`,
1878-
{
1879-
method: "PUT",
1880-
credentials: "same-origin",
1881-
headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" },
1882-
body: JSON.stringify({ data: { [field]: getBlocks() } }),
1883-
},
1884-
);
1885-
1886-
if (res.ok) {
1887-
initialRef.current = getBlocks();
1888-
document.dispatchEvent(new CustomEvent("emdash:save", { detail: { state: "saved" } }));
1889-
document.dispatchEvent(
1890-
new CustomEvent("emdash:content-changed", {
1891-
detail: { collection, id: entryId },
1892-
}),
1867+
const save = React.useCallback(
1868+
async (options?: { keepalive?: boolean }) => {
1869+
// A pagehide flush must not be skipped: an in-flight blur save is
1870+
// cancelled by the navigation, so the keepalive request is the only
1871+
// one that can still land (#1582).
1872+
if (savingRef.current && !options?.keepalive) return;
1873+
1874+
const current = JSON.stringify(getBlocks());
1875+
const initial = JSON.stringify(initialRef.current);
1876+
if (current === initial) return;
1877+
1878+
savingRef.current = true;
1879+
try {
1880+
const res = await fetch(
1881+
`/_emdash/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(entryId)}`,
1882+
{
1883+
method: "PUT",
1884+
credentials: "same-origin",
1885+
headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" },
1886+
body: JSON.stringify({ data: { [field]: getBlocks() } }),
1887+
keepalive: options?.keepalive ?? false,
1888+
},
18931889
);
1894-
} else {
1890+
1891+
if (res.ok) {
1892+
initialRef.current = getBlocks();
1893+
document.dispatchEvent(new CustomEvent("emdash:save", { detail: { state: "saved" } }));
1894+
document.dispatchEvent(
1895+
new CustomEvent("emdash:content-changed", {
1896+
detail: { collection, id: entryId },
1897+
}),
1898+
);
1899+
} else {
1900+
document.dispatchEvent(new CustomEvent("emdash:save", { detail: { state: "error" } }));
1901+
console.error("Save failed:", res.status);
1902+
}
1903+
} catch (err) {
18951904
document.dispatchEvent(new CustomEvent("emdash:save", { detail: { state: "error" } }));
1896-
console.error("Save failed:", res.status);
1905+
console.error("Save failed:", err);
1906+
} finally {
1907+
savingRef.current = false;
18971908
}
1898-
} catch (err) {
1899-
document.dispatchEvent(new CustomEvent("emdash:save", { detail: { state: "error" } }));
1900-
console.error("Save failed:", err);
1901-
} finally {
1902-
savingRef.current = false;
1903-
}
1904-
}, [collection, entryId, field, getBlocks]);
1909+
},
1910+
[collection, entryId, field, getBlocks],
1911+
);
1912+
1913+
// Flush unsaved edits when the page goes away (browser back/forward,
1914+
// link click, tab close). The blur handler doesn't cover this: unload
1915+
// doesn't reliably fire React blur, and a plain fetch started during
1916+
// unload is cancelled by the navigation — edits were silently lost
1917+
// (#1582). `keepalive` lets the PUT outlive the page.
1918+
// Caveat: keepalive caps the body at 64KB — a very long document
1919+
// can still be lost on unload. Upgrade path: debounced autosave
1920+
// while typing (like the admin editor) so unload flushes are rare.
1921+
React.useEffect(() => {
1922+
const flush = () => void save({ keepalive: true });
1923+
window.addEventListener("pagehide", flush);
1924+
return () => window.removeEventListener("pagehide", flush);
1925+
}, [save]);
19051926

19061927
// Create slash commands extension once — uses refs to avoid re-render loop
19071928
const slashCommandsExtension = React.useMemo(

0 commit comments

Comments
 (0)