From bf5ea7aa4f0cca724e3b17a2fb17739d22bd7a89 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 18:39:42 -0500 Subject: [PATCH] fix(tables): use fetch keepalive on the beforeunload save path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tables/js/save.js | 29 ++++++---- zddc/internal/handler/tables.html | 96 ++++++++++++++++++++++++++----- 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/tables/js/save.js b/tables/js/save.js index d223b94..1cdba94 100644 --- a/tables/js/save.js +++ b/tables/js/save.js @@ -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(); }); diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 8ce2a82..1e4dc80 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -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; +} + @@ -939,7 +967,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-09 16:07:14 · d3cd662-dirty + v0.0.17-alpha · 2026-05-09 23:35:43 · 3a4a1c7-dirty
@@ -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 = [ + '

ZDDC Form Renderer

', + '

This tool renders a form spec injected by zddc-server', + ' at <name>.form.html URLs. There is no schema', + ' to render here — most likely you opened the standalone HTML directly.

', + '

To use it

', + '
    ', + '
  1. Run zddc-server against an archive that contains a', + ' <name>.form.yaml spec.
  2. ', + '
  3. Visit <path>/<name>.form.html in the browser.
  4. ', + '
', + '

See ', + 'zddc.varasys.io/reference.html for the full ZDDC reference.

' + ].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) {