Files
windmill/frontend/src/lib/userDraft.svelte.ts
T
Ruben FiszelandClaude Opus 4.8 5cde2d5b67 fix(sessions): sync AI-session preview with workspace edits + stop phantom autosave (WIN-2160) (#10061)
* fix(sessions): sync AI-session editor preview with workspace edits + stop phantom autosave (WIN-2160)

Two related draft-sync fixes surfaced by the new AI sessions preview.

1. Session preview went stale after a workspace edit. A session's editor
   runtime cell (content store + loadedPath) outlives the sessions page: it
   survives toggling to workspace mode and MRU tab eviction. The shared
   per-user draft can change while the editor is unmounted — most visibly by
   editing the same item in the classic workspace editor, or from another
   device — but on the next mount the load early-returns on the still-set
   loadedPath and the preview keeps showing the pre-toggle content. Fix:
   invalidate the cell's loadedPath when SessionEditorTarget unmounts, so the
   next mount re-fetches the draft as a clean first load. This also sidesteps
   a Monaco model-reuse race (a force-reload that remounts the editor while
   the old one is still disposing renders a stale model) and prevents the
   outbound draft-sync from posting the stale store back (ready() stays false
   until the reload lands). Applies to all three editor kinds (script, flow,
   raw app) since they share SessionEditorTarget.

2. Opening a deployed script in the full-page editor autosaved a phantom
   draft with no user change. The deployed baseline carries a server-derived
   assets: [] that the editor's draft value never reproduces, so
   draftValuesEqual never matched baseline, discardIf returned false, and the
   settle-time write posted a no-op draft. Fix: ignore assets in the
   draft-vs-baseline comparison (it's derived from content, so it can't mask a
   real change).

Fixes WIN-2160

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sessions): condense teardown-invalidation comment to repo comment-length rule

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:19:08 +02:00

931 lines
32 KiB
TypeScript

import { get } from 'svelte/store'
import { onDestroy, untrack } from 'svelte'
import { deepEqual } from 'fast-equals'
import { workspaceStore } from './stores'
import { readFieldsRecursively } from './utils'
import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
import type { UserDraftItemKind } from './gen'
export type { UserDraftItemKind }
// Runtime mirror of the generated `UserDraftItemKind` union. `satisfies`
// + the exhaustiveness check below make a drift between the two a type error.
export const USER_DRAFT_ITEM_KINDS = [
'script',
'flow',
'app',
'raw_app',
'resource',
'variable',
'trigger_schedule',
'trigger_webhook',
'trigger_default_email',
'trigger_email',
'trigger_http',
'trigger_websocket',
'trigger_postgres',
'trigger_kafka',
'trigger_nats',
'trigger_mqtt',
'trigger_sqs',
'trigger_gcp',
'trigger_azure',
'trigger_poll',
'trigger_cli',
'trigger_nextcloud',
'trigger_google',
'trigger_github',
'data_pipeline'
] as const satisfies readonly UserDraftItemKind[]
// Reverse direction: every union member must appear in the array above.
type _AssertKindsExhaustive =
Exclude<UserDraftItemKind, (typeof USER_DRAFT_ITEM_KINDS)[number]> extends never ? true : never
const _: _AssertKindsExhaustive = true
void _
export type UserDraftOptions = {
workspace?: string
}
export type UserDraftListOptions = UserDraftOptions & {
itemKinds?: readonly UserDraftItemKind[]
}
type DraftState<V> = {
val: V | undefined
}
type DraftEntry = {
count: number
workspace: string
itemKind: UserDraftItemKind
path: string
state: DraftState<unknown>
/**
* Single-shot: skip the next reactive POST. Set by callers that already
* pushed the right thing (e.g. `discard`'s explicit `value: null` POST)
* before the reactive write, so the effect doesn't fire a duplicate.
*/
skipNextSync: boolean
/**
* Sticky `skipNextSync`: while true the sync effect updates local state
* but never POSTs. For programmatic bootstrap mutations (e.g. seeding
* `initialCode`). Toggled via `stopSync` / `restartSync`.
*/
syncSuspended: boolean
/**
* Single-shot: the next cell write is a programmatic SEED (baseline load
* / new-draft template), not a user edit. The effect adopts it as the
* baseline and skips the POST. Like `syncSuspended` but scoped to one
* write, so there's no suspension to forget to resume. Set via `seed`.
*/
seedNextWrite: boolean
/**
* Tears down the entry's autosave-mirror `$effect.root`; called once at
* refcount 0. The mirror is created detached from any component (see
* `acquireEntry`), so this is the ONLY thing that disposes it. Also flips
* `mirrorDisposed` to defuse the deferred creation if the entry is released
* before the mirror is even set up. In vitest the deferred callback never
* runs, so no root is created and this stays a no-op.
*/
destroyRoot?: () => void
/**
* Set by `destroyRoot` so the microtask-deferred mirror creation aborts when
* the entry was released (refcount 0) before the mirror was established.
*/
mirrorDisposed?: boolean
}
export type UserDraftEntry<V = unknown> = {
workspace: string
itemKind: UserDraftItemKind
path: string
value: V | undefined
}
export type LiveEditorDraft = {
workspace: string
itemKind: UserDraftItemKind
storagePath: string
effectivePath?: string
}
export type LiveEditorDraftSpec = {
itemKind: UserDraftItemKind
storagePath: string
effectivePath?: string
workspace?: string
}
export type ClearLiveEditorDraftOptions = UserDraftOptions & {
storagePath?: string
}
const entries = new Map<string, DraftEntry>()
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
/**
* Map keys whose entry should start `syncSuspended` on acquire. Lets
* callers `stopSync` BEFORE the editor has mounted (and called `use`).
* Consumed by `acquireEntry`; cleared by the matching `restartSync`. */
const pendingSuspensions = new Set<string>()
/**
* Synchronous read-through cache for values written via `save` while no live
* editor entry exists. That branch persists through the debounced
* `UserDraftDbSyncer` (async, fire-and-forget), so without this a same-tab
* `save(...)` followed by `get(...)` would miss its own write: the global AI
* chat writes a draft then immediately reads it back to return the result and
* would otherwise throw "Could not read written draft". A live entry shadows
* the cache (the entry is authoritative) and a release drops the key; a delete
* (`remove`/`discard`) evicts it.
*/
const writtenCache = new Map<
string,
{ workspace: string; itemKind: UserDraftItemKind; path: string; val: unknown }
>()
function rememberWrite(
workspace: string,
itemKind: UserDraftItemKind,
path: string,
val: unknown
): void {
const mk = mapKey(workspace, itemKind, path)
if (val === undefined) {
writtenCache.delete(mk)
} else {
writtenCache.set(mk, { workspace, itemKind, path, val: snapshotDraftValue(val) })
}
}
function resolveWorkspace(opts?: UserDraftOptions): string {
const ws = opts?.workspace ?? get(workspaceStore)
if (!ws) {
throw new Error(
'UserDraft: no workspace available (pass opts.workspace or set $workspaceStore)'
)
}
return ws
}
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `${workspace}/${itemKind}/${path}`
}
function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string {
return `${workspace}/${itemKind}`
}
function snapshotDraftValue<V>(value: V | undefined): V | undefined {
if (value === undefined) return undefined
try {
return structuredClone($state.snapshot(value)) as V
} catch {
try {
return JSON.parse(JSON.stringify(value)) as V
} catch {
return undefined
}
}
}
/**
* Top-level fields IGNORED when diffing a draft against its deployed
* baseline. `permissioned_as` / `preserve_permissioned_as` are deploy
* run-as directives, not draft content, and the editor round-trips them
* asymmetrically (`preserve_…` rebuilt as `!!cfg.permissioned_as` on load
* but `|| undefined` on build) — keeping them produces a phantom banner.
*
* The rest are server-managed read-time metadata that ride along on the
* loaded deployed payload but never appear in the editor's draft content, so
* comparing them would mask a true baseline match:
* `draft_saved_at` (the draft's own save time), `edited_at` (deploy time),
* `edited_by` (deploy author), `workspace_id`, `version_id` (deployed version),
* `is_draft` (backend presence flag), and `assets` (server-derived on deploy
* from the content — the editor's draft value never carries it, so a deployed
* baseline's `assets: []` would otherwise never equal the assets-less draft and
* every untouched load would autosave a phantom draft).
*/
const DRAFT_COMPARE_IGNORED_FIELDS = [
'permissioned_as',
'preserve_permissioned_as',
'extra_perms',
'draft_saved_at',
'edited_at',
'edited_by',
'workspace_id',
'version_id',
'parent_version',
'is_draft',
'assets'
] as const
/**
* Normalize one side of a draft-vs-baseline comparison: JSON round-trip
* (drafts are stored as json server-side, which strips `undefined` keys,
* so `{ labels: undefined }` and `{}` must compare equal) and drop the
* ignored fields above. Returns the input unchanged if unserializable.
*/
export function normalizeDraftForCompare<V>(value: V): V {
try {
const v = JSON.parse(JSON.stringify(value))
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
for (const f of DRAFT_COMPARE_IGNORED_FIELDS) delete v[f]
}
return v as V
} catch {
return value
}
}
/**
* Normalized deep equality for draft values — THE "diverges from deployed
* baseline" check. Editors must use it for BOTH their "unsaved changes"
* banner and the `discardIf` predicate so the two can't disagree.
*/
export function draftValuesEqual(a: unknown, b: unknown): boolean {
if (a === undefined || b === undefined) return a === b
return deepEqual(normalizeDraftForCompare(a), normalizeDraftForCompare(b))
}
export type UserDraftHandle<V> = {
get draft(): V | undefined
set draft(value: V | undefined)
}
export const UserDraft = {
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// The reactive effect in `acquireEntry` observes this write
// and POSTs it.
entry.state.val = value
} else {
// No live handle: remember the value so a same-tab read-after-write
// observes it synchronously (the syncer POST below is debounced),
// then persist. The next editor mount re-fetches from the backend.
rememberWrite(ws, itemKind, path, value)
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value })
}
},
/**
* Persist this key's CURRENT in-memory value to the server immediately,
* bypassing autosave's change-detection. For "Open in AI session" on a
* never-deployed item: it may have no draft row yet (an untouched new
* script's template seed never triggers autosave), and the session preview
* loads it via get-by-path, which 404s without a persisted draft. No-op
* when no value is held for this key. Resolves once the POST lands.
*
* MUST NOT be a general save path: it skips the handle's `discardIf`
* baseline check, so a value back at the deployed baseline would persist a
* no-op draft instead of the delete autosave sends. Only safe for
* never-deployed items, which have no baseline.
*/
async forcePersist(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftOptions
): Promise<void> {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
const val = entry ? entry.state.val : writtenCache.get(mk)?.val
if (val === undefined) return
await UserDraftDbSyncer.save({
workspace: ws,
itemKind,
path,
value: snapshotDraftValue(val),
immediate: true
})
},
/**
* Current draft value from the in-memory cell. `undefined` when no
* editor has mounted a handle for this key in this tab.
*/
get<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftOptions
): V | undefined {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) return snapshotDraftValue(entry.state.val as V | undefined)
const cached = writtenCache.get(mk)
if (cached) return snapshotDraftValue(cached.val as V | undefined)
return undefined
},
/**
* Whether a draft exists for this key in the in-memory cache. False when
* no editor has mounted a handle for it yet.
*/
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) return entry.state.val !== undefined
return writtenCache.get(mk)?.val !== undefined
},
remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Clear the cell so live observers see the delete; arm
// `skipNextSync` since we POST the `null` explicitly below.
entry.skipNextSync = true
entry.state.val = undefined
}
writtenCache.delete(mk)
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null })
},
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
UserDraft.discard(itemKind, path, undefined, opts)
},
/**
* Suspend reactive sync for this key: writes still update the cell and
* subscribers but don't POST. Use to bracket programmatic bootstrap
* mutations (seeding `initialCode`, app init) so they don't appear as
* the user's "first edit".
*
* Safe BEFORE the entry is live (queued, applied on acquire). MUST pair
* every `stopSync` with a `restartSync` — forgetting silently disables
* autosave for the rest of the session.
*/
stopSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) entry.syncSuspended = true
else pendingSuspensions.add(mk)
},
/**
* Resume reactive sync after `stopSync`. Subsequent differing writes
* POST normally; writes made during the suspension are dropped from the
* server's view (the cell still reflects them). Also clears a queued
* (pre-acquire) suspension.
*/
restartSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
pendingSuspensions.delete(mk)
const entry = entries.get(mk)
if (entry) entry.syncSuspended = false
},
/**
* Load `value` into the cell as a SEED (baseline reload / new-draft
* template) that must NOT POST as the user's edit. Reactive readers
* update immediately; the sync effect adopts it as the new baseline.
*
* Prefer over the `stopSync`/`restartSync` bracket for a single write —
* scoped to exactly this write, nothing to forget to resume. The bracket
* is still needed when a write fans out across components (e.g. an
* editor's `initContent` cascading into the bound value).
*
* No-op if the entry isn't live yet (acquire via `use`/`useMany` first).
*/
seed<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (!entry) return
entry.seedNextWrite = true
entry.state.val = snapshotDraftValue(value)
},
/**
* Currently-mounted live entries for `workspace` (in-tab only — for a
* workspace-wide view call `DraftService` directly).
*/
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
const ws = resolveWorkspace(opts)
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
const out: UserDraftEntry<V>[] = []
const seen = new Set<string>()
for (const entry of entries.values()) {
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
const val = untrack(() => entry.state.val as V | undefined)
if (val === undefined) continue
seen.add(mapKey(entry.workspace, entry.itemKind, entry.path))
out.push({
workspace: entry.workspace,
itemKind: entry.itemKind,
path: entry.path,
value: snapshotDraftValue(val)
})
}
// Drafts written without a live entry (e.g. global AI chat) live only in
// `writtenCache`; surface them too so the list matches what `get` returns.
for (const cached of writtenCache.values()) {
if (cached.workspace !== ws || !itemKinds.includes(cached.itemKind)) continue
const mk = mapKey(cached.workspace, cached.itemKind, cached.path)
if (seen.has(mk)) continue
out.push({
workspace: cached.workspace,
itemKind: cached.itemKind,
path: cached.path,
value: snapshotDraftValue(cached.val as V | undefined)
})
}
return out
},
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
const ws = resolveWorkspace({ workspace: spec.workspace })
liveEditorDrafts.set(liveEditorDraftKey(ws, spec.itemKind), {
workspace: ws,
itemKind: spec.itemKind,
storagePath: spec.storagePath,
effectivePath: spec.effectivePath || undefined
})
},
getLiveEditorDraft(
itemKind: UserDraftItemKind,
opts?: UserDraftOptions
): LiveEditorDraft | undefined {
const ws = resolveWorkspace(opts)
const draft = liveEditorDrafts.get(liveEditorDraftKey(ws, itemKind))
return draft ? { ...draft } : undefined
},
clearLiveEditorDraft(itemKind: UserDraftItemKind, opts?: ClearLiveEditorDraftOptions): void {
const ws = resolveWorkspace(opts)
const key = liveEditorDraftKey(ws, itemKind)
const draft = liveEditorDrafts.get(key)
if (!draft) return
if (opts?.storagePath !== undefined && draft.storagePath !== opts.storagePath) return
liveEditorDrafts.delete(key)
},
/**
* Like `remove`, but also resets any live handle's `draft` to `fallback`
* in-memory (reactive readers see it at once). The explicit `value: null`
* POST is the canonical delete; the cell update is UI convenience.
*
* `fallback` MUST be deep-cloned: otherwise a caller passing their own
* live `$state` baseline (e.g. `initialStates[ws]`) would share a proxy
* with `handle.draft`, so edits mutate both sides and the dirty check
* never fires.
*/
discard<V>(
itemKind: UserDraftItemKind,
path: string,
fallback: V | undefined,
opts?: UserDraftOptions & {
/** Mark the `value: null` POST as a reactive autosave (subject to
* the auto-save toggle) instead of an explicit action. Set by the
* trigger persist-effect's at-baseline discard; explicit discards
* leave it unset. */
auto?: boolean
}
): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
const safeFallback = snapshotDraftValue(fallback)
if (entry) {
entry.skipNextSync = true
entry.state.val = safeFallback
}
// The draft is deleted server-side (the `null` POST below); the fallback
// only resets the live handle's UI. Drop the cache so a no-entry read
// reports "no draft" rather than the discarded value.
writtenCache.delete(mk)
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null, auto: opts?.auto })
},
use<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftOptions & {
/** See the `useMany` spec field. Default `false`. */
canBeDisabled?: boolean
/** See the `useMany` spec field. Captured once on first acquire. */
discardIf?: (val: V) => boolean
}
): UserDraftHandle<V> {
// Single-spec wrapper around `useMany`. `untrack` captures reactive
// opts (e.g. `$workspaceStore`) once: the handle stays bound to this
// workspace until unmount. For reactive `(kind, path)` use `useReactive`.
const handles = UserDraft.useMany<V>(() =>
untrack(() => [
{
itemKind,
path,
workspace: opts?.workspace,
canBeDisabled: opts?.canBeDisabled,
discardIf: opts?.discardIf
}
])
)
return handles[0]
},
/**
* Reactive single-spec variant of `use()`. `getSpec` is read inside the
* `useMany` reconcile, so a changed `(workspace, kind, path)` releases the
* old entry and acquires a new one. The returned object is a stable proxy
* forwarding `draft` to the current handle, so `bind:` lvalues survive
* re-keying. Use for reactive paths (`/scripts/edit/[...path]`).
*/
useReactive<V = unknown>(
getSpec: () => {
itemKind: UserDraftItemKind
path: string
workspace?: string
canBeDisabled?: boolean
/** See the `useMany` spec field. Seeds the cell on first acquire
* without POSTing. Pass a STABLE reference (read it under `untrack`)
* — it's consumed once, so re-reading reactive state here only churns
* the reconcile. */
defaultValue?: V
/** See the `useMany` spec field. Captured per re-keyed acquire. */
discardIf?: (val: V) => boolean
}
): UserDraftHandle<V> {
const handles = UserDraft.useMany<V>(() => [getSpec()])
return {
get draft(): V | undefined {
return handles[0]?.draft
},
set draft(value: V | undefined) {
const h = handles[0]
if (h) h.draft = value
}
}
},
useMany<V = unknown>(
getSpecs: () => {
itemKind: UserDraftItemKind
path: string
workspace?: string
/**
* Value the entry's cell is seeded with on first acquire. Swallowed
* by the syncer's seed guard so it never POSTs. Ignored if the
* entry already exists (refcount > 0, e.g. another live handle
* keeps its value).
*/
defaultValue?: V
/**
* Predicate: is the value about to autosave back at the deployed
* baseline? When true, the mirror POSTs a delete instead of a
* baseline-equal copy — keeping it would leave `is_draft` (and the
* list `*`) stuck on. MUST use the same comparison as the "unsaved
* changes" banner (`draftValuesEqual`) so the two can't disagree.
* Return false for draft-only items (no deployed copy — deleting on
* equality would destroy the item). Captured on first acquire.
*/
discardIf?: (val: V) => boolean
/**
* Subject autosaves to the auto-save toggle. Only the full-page
* editors (script / flow / app / raw app) opt in; while off their
* keystroke saves park for Ctrl/Cmd+S. Default `false`: drawer
* editors always sync. Captured on first acquire.
*/
canBeDisabled?: boolean
}[]
): UserDraftHandle<V>[] {
// Handles array reconciled against `getSpecs()`, indices aligned.
// Same-key handles are reused across reconciles so callers can hold
// a stable reference — only the entry's refcount moves.
const handles = $state<UserDraftHandle<V>[]>([])
const acquired = new Set<string>()
const handleCache = new Map<string, UserDraftHandle<V>>()
// `defaultValue` reference last used to seed each detached (empty-path)
// handle. The reference is stable within an editing session but swapped
// for a fresh clone each time the caller restarts (e.g. reopening the
// new-item drawer) — so a change here means "re-seed", not "live edit".
const detachedSeeds = new Map<string, unknown>()
function reconcile() {
const specs = getSpecs()
const seen = new Set<string>()
const next: UserDraftHandle<V>[] = []
for (const spec of specs) {
// Resolve the workspace WITHOUT throwing: a reactive caller (e.g. an
// SDK editor mounted before login) may not have one yet. An absent
// workspace is handled like an empty path below, so the handle
// re-keys into a real entry once the workspace resolves.
const ws = spec.workspace ?? get(workspaceStore) ?? undefined
const mk = mapKey(ws ?? '', spec.itemKind, spec.path)
// No workspace yet, or empty path = no draftable item (e.g. a
// read-only historical-hash view that still binds an editor value).
// Acquiring would mirror edits into an unroutable
// `POST /drafts/update/kind/` (permanent "Save failed").
// Hand out a detached, local-only handle instead.
if (!ws || !spec.path) {
seen.add(mk)
let handle = handleCache.get(mk)
// Drop the cached handle when the caller hands in a fresh
// `defaultValue` reference (reopening the new-item drawer seeds a
// new clone) so the rebuilt handle re-seeds instead of replaying
// the previous session's edits. Stable reference within a session
// means live edits are never clobbered.
if (handle && detachedSeeds.get(mk) !== spec.defaultValue) {
handleCache.delete(mk)
handle = undefined
}
if (!handle) {
// Seed with `defaultValue` so consumers (e.g. the new-variable
// drawer, whose path is empty until the user types one) get a
// populated cell to bind their form to instead of `undefined`.
handle = makeDetachedHandle<V>(spec.defaultValue)
handleCache.set(mk, handle)
detachedSeeds.set(mk, spec.defaultValue)
}
next.push(handle)
continue
}
seen.add(mk)
if (!acquired.has(mk)) {
acquireEntry(
ws,
spec.itemKind,
spec.path,
spec.defaultValue,
spec.discardIf as ((val: unknown) => boolean) | undefined,
spec.canBeDisabled ?? false
)
acquired.add(mk)
}
let handle = handleCache.get(mk)
if (!handle) {
handle = makeHandle<V>(ws, spec.itemKind, spec.path)
handleCache.set(mk, handle)
}
next.push(handle)
}
for (const mk of [...acquired]) {
if (!seen.has(mk)) {
releaseEntry(mk)
acquired.delete(mk)
handleCache.delete(mk)
}
}
// Detached handles (empty-path) live only in `handleCache` — they're
// never in `acquired`. Drop any that fell out of the specs so they
// don't leak and a later reappearance rebuilds from scratch.
for (const mk of [...handleCache.keys()]) {
if (!acquired.has(mk) && !seen.has(mk)) {
handleCache.delete(mk)
detachedSeeds.delete(mk)
}
}
// Skip no-op mutations (cached handles → reference-equal arrays).
// `untrack` so this effect doesn't subscribe to its own `handles`
// write — otherwise it self-loops (`effect_update_depth_exceeded`).
untrack(() => {
const unchanged = handles.length === next.length && handles.every((h, i) => h === next[i])
if (!unchanged) handles.splice(0, handles.length, ...next)
})
}
// Synchronous initial reconcile so single-spec callers get a populated
// `handles[0]` before returning. `untrack` here — the `$effect` below
// picks up subsequent changes.
untrack(reconcile)
$effect(reconcile)
onDestroy(() => {
// Flush each entry's pending autosave BEFORE releasing it. SPA
// nav doesn't fire `pagehide`, so a debounced edit would silently
// vanish when the editor unmounts mid-typing. Fire-and-forget —
// the POST rides the runner's own lifetime, which outlives this
// component, so destroying the cell here doesn't cancel it.
//
// `honorAutosaveToggle`: this unmount flush is an implicit autosave,
// so a toggle-aware handle whose auto-save is off must NOT persist on
// leave — the editor's UnsavedConfirmationModal warns the user instead.
for (const mk of acquired) {
const entry = entries.get(mk)
if (!entry) continue
void UserDraftDbSyncer.flush(
{
workspace: entry.workspace,
itemKind: entry.itemKind,
path: entry.path
},
{ honorAutosaveToggle: true }
)
}
for (const mk of acquired) releaseEntry(mk)
acquired.clear()
handleCache.clear()
})
return handles
}
}
function acquireEntry(
workspace: string,
itemKind: UserDraftItemKind,
path: string,
defaultValue?: unknown,
discardIf?: (val: unknown) => boolean,
canBeDisabled = false
): void {
const mk = mapKey(workspace, itemKind, path)
const existing = entries.get(mk)
if (existing) {
existing.count++
return
}
// Seed the cell with `defaultValue` (deep-cloned). The mirror below anchors
// its baseline to this seed so it never POSTs the template.
const seed = defaultValue !== undefined ? snapshotDraftValue(defaultValue) : undefined
const cell = $state<{ val: unknown }>({ val: seed })
const stateRef = cell as DraftState<unknown>
const entry: DraftEntry = {
count: 1,
workspace,
itemKind,
path,
state: stateRef,
skipNextSync: false,
syncSuspended: pendingSuspensions.delete(mk),
seedNextWrite: false,
mirrorDisposed: false,
// Placeholder until the deferred `createMirror` swaps in the real teardown;
// flips `mirrorDisposed` so a release before then aborts the creation.
destroyRoot: () => {
entry.mirrorDisposed = true
}
}
entries.set(mk, entry)
const createMirror = (): void => {
// Bail if this entry was released before the microtask ran, or if the key
// was released and re-acquired (a different entry now owns `mk` — its own
// `createMirror` will run).
if (entry.mirrorDisposed || entries.get(mk) !== entry) return
entry.destroyRoot = $effect.root(() => {
// Mirror every observable change of `cell.val` to the syncer.
// `readFieldsRecursively` walks the value so deep mutations
// (`handle.draft.content = '...'`) re-fire the effect — reading
// `cell.val` alone only subscribes to the proxy root.
//
// `lastSerialized` + `skipNextWrite` dedup no-op updates and treat
// the seed/restore as no-POST, so landing on `?new_draft` doesn't
// sync until the user edits.
//
// The swallow is anchored to the seed VALUE, not "first change after
// mount": when a `defaultValue` seeded the cell, `lastSerialized`
// starts at its serialization so a first mirror run that still equals
// the seed is a genuine no-op, while a first run that already DIFFERS
// is the user's edit and must POST. That closes a create-path race —
// the mirror is created in a deferred microtask (see below) and the
// handle re-keys detached→acquired as the workspace resolves, so the
// seed and the user's first edit can land before the mirror's first
// run and coalesce into it; a blind "swallow the first change" would
// eat that edit.
//
// With no in-hand seed (page editors load & assign the value AFTER
// mount), arm the blind first-write swallow so that post-load
// assignment doesn't POST.
//
// `cell.val === undefined` is the delete signal (`value: null`).
// `skipNextSync` lets callers that already POSTed (`discard`,
// `remove`) suppress the duplicate fire from their own write.
let lastSerialized: string | undefined = seed === undefined ? undefined : JSON.stringify(seed)
let skipNextWrite = seed === undefined
$effect(() => {
const val = cell.val
if (val !== undefined) readFieldsRecursively(val)
const next = val === undefined ? undefined : JSON.stringify(val)
if (next === lastSerialized) {
// No-op write. If a `seed` re-seeded the value already in the
// cell, its one-shot flag consumed nothing — defuse it here or
// it lingers and swallows the user's NEXT edit. (`skipNextWrite`
// stays armed: an undefined-seeded cell's initial run lands
// here, and page editors rely on it to swallow their load write.)
if (entry.seedNextWrite) entry.seedNextWrite = false
return
}
lastSerialized = next
if (skipNextWrite) {
skipNextWrite = false
// One write consumes BOTH one-shot guards: a `seed` on a fresh
// entry (still armed with the first-write skip) must not leave
// `seedNextWrite` behind to swallow the user's first edit.
if (entry.seedNextWrite) entry.seedNextWrite = false
return
}
// `seed` write: baseline already advanced above; don't POST.
if (entry.seedNextWrite) {
entry.seedNextWrite = false
return
}
if (entry.skipNextSync) {
entry.skipNextSync = false
return
}
// `syncSuspended` swallows the POST but `lastSerialized` advanced
// above, so only writes made during suspension are dropped — the
// first change after resume is still detected.
if (entry.syncSuspended) return
// At the deployed baseline → sync a delete, not a baseline-equal
// copy. `untrack` so reactive reads in the predicate (the editor's
// post-deploy baseline) don't re-fire the mirror.
const atBaseline = untrack(() => val !== undefined && (discardIf?.(val) ?? false))
void UserDraftDbSyncer.save({
workspace,
itemKind,
path,
value: val === undefined || atBaseline ? null : val,
// Reactive keystroke mirror — `auto`, so suppressed while the
// auto-save toggle is off (for `canBeDisabled` handles).
auto: true,
canBeDisabled
})
})
})
}
// Defer so the root is created with no active component/effect — a top-level
// scope owned by the entry, not by the acquiring component. An inline
// `$effect.root` would die when that component unmounts while other holders
// still edit the same draft (see `destroyRoot`).
queueMicrotask(createMirror)
}
function releaseEntry(mk: string): void {
const entry = entries.get(mk)
if (!entry) return
entry.count--
// The mirror is owned by the ENTRY (created detached — see `acquireEntry`),
// not by any holder, so releasing one holder never touches it; it is disposed
// only here, once, at refcount 0. This is what lets multiple holders (warm
// session previews + the nav editor) share the entry and drop in any order.
if (entry.count <= 0) {
// The live entry was authoritative while mounted; once gone, drop any
// cached write for this key so a later read falls back to the server
// rather than a value the editor may have changed in the meantime.
writtenCache.delete(mk)
entry.destroyRoot?.()
entries.delete(mk)
}
}
/**
* Local-only handle for empty-path specs: a reactive cell that supports
* `bind:` but is wired to nothing (no entry, no sync, no POSTs). For views
* that bind an editor value with no draftable item behind it.
*/
function makeDetachedHandle<V>(defaultValue?: V): UserDraftHandle<V> {
let val = $state<V | undefined>(snapshotDraftValue(defaultValue))
return {
get draft(): V | undefined {
return val
},
set draft(value: V | undefined) {
val = value
}
}
}
function makeHandle<V>(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): UserDraftHandle<V> {
// Reads `entries.get(mk)` on every access; the entry is stable while
// refcount > 0. After destruction, reads return `undefined` rather than
// throwing — the consumer should already be torn down by then.
const mk = mapKey(workspace, itemKind, path)
const stateOf = (): DraftState<unknown> | undefined => entries.get(mk)?.state
return {
get draft(): V | undefined {
return stateOf()?.val as V | undefined
},
set draft(value: V | undefined) {
const state = stateOf()
if (state) state.val = value
}
}
}
/** Test-only: clear all in-memory entries. */
export function __resetUserDraftForTesting(): void {
entries.clear()
liveEditorDrafts.clear()
writtenCache.clear()
}