feat: add UserDraft.stopSync/restartSync, wire on script + low-code app /add init

This commit is contained in:
Diego Imbert
2026-06-04 16:18:58 +02:00
parent 4110abcb77
commit 1709f6978c
3 changed files with 79 additions and 3 deletions
@@ -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() {
@@ -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>('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) {
+49 -1
View File
@@ -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
})
}