fix(tables): use fetch keepalive on the beforeunload save path

The TODO at save.js's unload handler was "switch to keepalive on save
for the unload path." flushAllDrafts() kicks off saveRow() per dirty
row when the page is being navigated away from, but those fetches were
not flagged keepalive — modern browsers can cancel them mid-flight as
the page unloads, dropping the user's last typing.

saveRow() now accepts an opts.keepalive flag that is passed through to
fetch(). flushAllDrafts() passes {keepalive: true} so the unload path
gets the keepalive guarantee. Normal saves are unaffected (keepalive
imposes a 64 KB body cap per the Fetch spec — only worth that trade
on the unload path).

Also refreshes the embedded zddc/internal/handler/tables.html bytes via
./build, which folds in this change plus the form welcome-state CSS
from c585112.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 18:39:42 -05:00
parent c58511232f
commit bf5ea7aa4f
2 changed files with 100 additions and 25 deletions

View file

@ -174,7 +174,8 @@
// --- The save itself ---------------------------------------------
async function saveRow(rowId) {
async function saveRow(rowId, opts) {
opts = opts || {};
const { row, drafts } = rowFromState(rowId);
if (!row || !drafts || Object.keys(drafts).length === 0) {
return { status: 'noop' };
@ -196,14 +197,20 @@
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
const fetchOpts = {
method: 'PUT',
body: yamlBody,
headers: headers,
credentials: 'same-origin',
};
// The unload path passes keepalive:true so the PUT outlives the
// page navigation. Subject to the spec's 64 KB body cap — large
// rows may fail in that path; normal saves are unaffected.
if (opts.keepalive) fetchOpts.keepalive = true;
let resp;
try {
resp = await fetch(row.yamlUrl, {
method: 'PUT',
body: yamlBody,
headers: headers,
credentials: 'same-origin',
});
resp = await fetch(row.yamlUrl, fetchOpts);
} catch (err) {
// Network failure — outbox-fronted client should still
// resolve with 202; reaching here means a hard client-side
@ -382,15 +389,13 @@
const drafts = app.state.drafts || {};
const ids = Object.keys(drafts);
for (let i = 0; i < ids.length; i++) {
saveRow(ids[i]).catch(() => {});
saveRow(ids[i], { keepalive: true }).catch(() => {});
}
}
// Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. Best-effort; modern browsers
// limit what beforeunload can do but a fetch with keepalive: true
// gives us one shot. (TODO: switch to keepalive on save for the
// unload path.)
// doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap.
window.addEventListener('beforeunload', function (_ev) {
flushAllDrafts();
});

View file

@ -924,6 +924,34 @@ body.help-open .app-header {
cursor: not-allowed;
}
/* Standalone welcome — shown when form.html is opened directly (no server-injected #form-context). */
.form-welcome {
max-width: 36rem;
margin: 2rem auto;
padding: 1.5rem 1.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.form-welcome h2 {
margin-bottom: 0.5rem;
font-size: 1.25rem;
}
.form-welcome h3 {
margin: 1rem 0 0.35rem;
font-size: 0.95rem;
}
.form-welcome p { margin-bottom: 0.75rem; line-height: 1.5; }
.form-welcome ol { margin: 0 0 0.75rem 1.25rem; }
.form-welcome li { margin-bottom: 0.35rem; }
.form-welcome code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--bg-secondary);
padding: 0.05em 0.3em;
border-radius: 3px;
}
</style>
</head>
<body>
@ -939,7 +967,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 16:07:14 · d3cd662-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 23:35:43 · 3a4a1c7-dirty</span></span>
</div>
</div>
<div class="header-right">
@ -3798,7 +3826,8 @@ body.help-open .app-header {
// --- The save itself ---------------------------------------------
async function saveRow(rowId) {
async function saveRow(rowId, opts) {
opts = opts || {};
const { row, drafts } = rowFromState(rowId);
if (!row || !drafts || Object.keys(drafts).length === 0) {
return { status: 'noop' };
@ -3820,14 +3849,20 @@ body.help-open .app-header {
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
const fetchOpts = {
method: 'PUT',
body: yamlBody,
headers: headers,
credentials: 'same-origin',
};
// The unload path passes keepalive:true so the PUT outlives the
// page navigation. Subject to the spec's 64 KB body cap — large
// rows may fail in that path; normal saves are unaffected.
if (opts.keepalive) fetchOpts.keepalive = true;
let resp;
try {
resp = await fetch(row.yamlUrl, {
method: 'PUT',
body: yamlBody,
headers: headers,
credentials: 'same-origin',
});
resp = await fetch(row.yamlUrl, fetchOpts);
} catch (err) {
// Network failure — outbox-fronted client should still
// resolve with 202; reaching here means a hard client-side
@ -4006,15 +4041,13 @@ body.help-open .app-header {
const drafts = app.state.drafts || {};
const ids = Object.keys(drafts);
for (let i = 0; i < ids.length; i++) {
saveRow(ids[i]).catch(() => {});
saveRow(ids[i], { keepalive: true }).catch(() => {});
}
}
// Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. Best-effort; modern browsers
// limit what beforeunload can do but a fetch with keepalive: true
// gives us one shot. (TODO: switch to keepalive on save for the
// unload path.)
// doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap.
window.addEventListener('beforeunload', function (_ev) {
flushAllDrafts();
});
@ -5285,6 +5318,37 @@ body.help-open .app-header {
(function (app) {
'use strict';
// Friendly empty-state shown when the form is opened standalone
// (file:// or otherwise without a server-injected #form-context
// payload). The form renderer is always driven by the host —
// zddc-server's form handler injects schema+ui+data; the tool has
// no client-side picker because there's nothing it could pick from
// outside that contract.
function renderStandaloneWelcome(root) {
if (!root) return;
root.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'form-welcome';
wrap.innerHTML = [
'<h2>ZDDC Form Renderer</h2>',
'<p>This tool renders a form spec injected by <code>zddc-server</code>',
' at <code>&lt;name&gt;.form.html</code> URLs. There is no schema',
' to render here — most likely you opened the standalone HTML directly.</p>',
'<h3>To use it</h3>',
'<ol>',
'<li>Run <code>zddc-server</code> against an archive that contains a',
' <code>&lt;name&gt;.form.yaml</code> spec.</li>',
'<li>Visit <code>&lt;path&gt;/&lt;name&gt;.form.html</code> in the browser.</li>',
'</ol>',
'<p>See <a href="https://zddc.varasys.io/reference.html" target="_blank" rel="noopener">',
'zddc.varasys.io/reference.html</a> for the full ZDDC reference.</p>'
].join('');
root.appendChild(wrap);
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.hidden = true;
}
function boot() {
// When this bundle is hosted by the unified tables.html, the
// mode dispatcher decides which app paints. Skip when mode is
@ -5316,6 +5380,12 @@ body.help-open .app-header {
app.context.ui || {},
app.context.data
);
} else if (root) {
// No schema — server-injected context is empty. Most common
// when the standalone form.html is opened from file:// without
// a host. Show a friendly explanation instead of a blank page.
renderStandaloneWelcome(root);
return;
}
if (app.context.errors && app.context.errors.length) {