diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 7b7ae4d0ef..b611f9f66c 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -100,6 +100,7 @@ import { buildForkEditUrl } from '$lib/utils/editInFork' import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' import WacExportDrawer from './scripts/WacExportDrawer.svelte' + import { UserDraft } from '$lib/userDraft.svelte' let { script = $bindable(), @@ -343,6 +344,14 @@ let loadingSave = $state(false) if (script.content == '') { + // Suspend autosave around the bootstrap mutations — seeding the + // editor with the template's `initialCode` is a programmatic + // write that shouldn't count as the user's "first edit" and + // shouldn't POST to the server. The route's UserDraft handle is + // keyed by `initialPath` (the URL path). Resumed in the async + // `.finally` so language switches AFTER bootstrap (which also + // call `initContent`) sync normally. + UserDraft.stopSync('script', initialPath) if (template === 'wac_python') { script.modules = { 'helper.py': { @@ -358,7 +367,9 @@ } } } - initContent(script.language, script.kind, template) + initContent(script.language, script.kind, template).finally(() => { + UserDraft.restartSync('script', initialPath) + }) } async function isTemplateScript() { diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 03917e84ae..23a6ac51f0 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -3,7 +3,7 @@ const bubble = createBubbler() import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte' - import { getContext, onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, tick, untrack } from 'svelte' import { twMerge } from 'tailwind-merge' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -94,6 +94,15 @@ const appDraftPath = newApp ? '' : (path ?? '') const appDraftHandle = inSessionPane ? undefined : UserDraft.use('app', appDraftPath) + // Suspend autosave around mount — the route may have seeded `app` + // from an empty template (e.g. `/apps/add` redirect with + // `new_draft=true`), and the `firstMirror` effect below writes that + // seed into the handle as a programmatic mutation. Without + // suspension that write fires a POST that looks like the user's + // first edit before they've touched anything. `onMount`-then-`tick` + // resumes once all mount-time effects have settled, so the user's + // real first edit is the first POST. + if (appDraftHandle) UserDraft.stopSync('app', appDraftPath) // Prefer the persisted autosave over the prop when both exist (e.g. // /apps/add reload: the route always initializes `app` to an empty // template, but the user's last session is sitting in LS under the @@ -481,6 +490,14 @@ let mounted = false onMount(() => { mounted = true + // Resume autosave now that mount-time effects (the + // `firstMirror` mirror, prop-driven initialization, ...) have + // all run. `tick` waits for the current pending effect flush + // to complete so the post-suspend writes have been observed by + // the sync effect and silently dropped. + if (appDraftHandle) { + tick().then(() => UserDraft.restartSync('app', appDraftPath)) + } setTimeout(() => { if ($initialized?.initialized === false) { diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 0f1caad860..bff2c923ef 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -117,6 +117,15 @@ type DraftEntry = { * incorrect POST. */ skipNextSync: boolean + /** + * Sticky version of `skipNextSync`. While true, the reactive sync + * effect updates the local state but never POSTs — used by callers + * that programmatically mutate the draft as part of bootstrapping + * (e.g. setting the editor's `initialCode` after mount) and don't + * want those writes to land on the server as the user's "first + * autosave". Toggled via `UserDraft.stopSync` / `restartSync`. + */ + syncSuspended: boolean /** * Tears down the `$effect.root` scope that owns the entry's sync * effect. Called when the refcount hits 0. `undefined` only when the @@ -410,6 +419,38 @@ export const UserDraft = { UserDraft.discard(itemKind, path, undefined, opts) }, + /** + * Suspend the reactive sync for `(workspace, itemKind, path)`. + * Writes after this call still update the in-memory cell and any + * subscribers but don't POST to the syncer. Use to bracket + * programmatic mutations that happen during editor bootstrap (e.g. + * seeding script content from `initialCode`, low-code app init) + * so they don't appear on the server as the user's "first edit". + * + * The entry must already be live (acquired by `use`/`useMany`); a + * no-op otherwise. Pair every `stopSync` with a `restartSync` — + * forgetting to resume silently turns off autosave for the rest of + * the session. + */ + stopSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + const ws = resolveWorkspace(opts) + const entry = entries.get(mapKey(ws, itemKind, path)) + if (entry) entry.syncSuspended = true + }, + + /** + * Resume reactive sync for `(workspace, itemKind, path)` after a + * `stopSync`. Subsequent writes that differ from the suspended-time + * state are POSTed normally; writes made during the suspension are + * dropped from the server's view (the local cell still reflects + * them). No-op if the entry isn't live or wasn't suspended. + */ + restartSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + const ws = resolveWorkspace(opts) + const entry = entries.get(mapKey(ws, itemKind, path)) + if (entry) entry.syncSuspended = false + }, + /** * List currently-mounted live entries for `workspace`. Without the * localStorage layer, "list" is meaningful only for in-tab entries — @@ -649,6 +690,11 @@ function acquireEntry( entry.skipNextSync = false return } + // `syncSuspended` swallows the POST but still advances + // `lastSerialized` (above) so when sync resumes the next + // real change is detected as a change — only the writes + // made during suspension are dropped from the server's view. + if (entry?.syncSuspended) return void UserDraftDbSyncer.save({ workspace, itemKind, @@ -665,6 +711,7 @@ function acquireEntry( path, state: stateRef, skipNextSync: false, + syncSuspended: false, destroyRoot }) return @@ -680,7 +727,8 @@ function acquireEntry( itemKind, path, state: fallback, - skipNextSync: false + skipNextSync: false, + syncSuspended: false }) }