feat(frontend): GC UserDraft entries older than 30 days

Without a sweep, a heavy user accumulates one localStorage entry per
(workspace, kind, path) they ever touched. The pre-PR single-key
autosave self-capped at one entry per editor; this one needs an
explicit GC pass.

Mechanism:
- Stamp every persist with `lastWrittenAt: Date.now()`. Added at four
  sites: `useLocalStorageValue`'s new `transformBeforePersist`
  option (covers both setter and deep-mutation persists),
  `UserDraft.save`'s no-handle fallback, `persistDirect` (force-meta
  writes), and the legacy migration. Done at persist time, not in
  `wrap()`, so deep mutations bump the clock too — `wrap()` runs only
  on `.draft =` assignments, which would leave the timestamp stale for
  bind-mutated editor sessions.
- `gcUserDrafts(maxAgeMs = 30d)` walks every `userdraft/w/...` key,
  removes the ones older than the cutoff. Entries written before this
  field existed (pre-PR or pre-this-commit) get backfilled with the
  current time on first sweep so a 30-day clock starts fresh; the
  alternative — sweeping on sight — would wipe work that the legacy
  migration just rescued.
- Wired into the logged-in layout: runs once on mount and every 30 min
  via `setInterval` (cleaned up in the effect's return).

Tests use `vi.setSystemTime` to drive the clock; assertions on the
stored payload now go through a `storedShape` helper that strips
`lastWrittenAt` before string-comparing, so the existing
`expect(...).toBe(wrapped(...))` style still reads cleanly. New tests
cover the sweep, the backfill behaviour, the default 30d window, and
a custom `maxAgeMs`.
This commit is contained in:
Diego Imbert
2026-05-15 15:01:20 +02:00
parent 65b5be8eff
commit 2d432c6308
6 changed files with 219 additions and 38 deletions
+13 -1
View File
@@ -590,10 +590,21 @@ export function useLocalStorageValue<T>(
* close drops it.
*/
debounce?: number
/**
* Transform applied to the value just before serialisation, on every
* persist (both setter-driven and deep-mutation-driven). The in-memory
* `$state` is left as-is. Useful for injecting per-write metadata
* (timestamps, counters) that must reflect the actual write time, not
* the last `.val =` assignment — deep mutations don't re-run the
* setter, so a timestamp set via `.val =` would otherwise grow stale
* across long editing sessions.
*/
transformBeforePersist?: (val: T) => T
}
): { val: T } {
const saveInitialValue = options?.saveInitialValue ?? true
const debounceMs = options?.debounce ?? 0
const transformBeforePersist = options?.transformBeforePersist
const serialize = (val: T) =>
typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val)
const deserialize = (val: string): T => {
@@ -607,7 +618,8 @@ export function useLocalStorageValue<T>(
if (val === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, serialize(val as T))
const toStore = transformBeforePersist ? transformBeforePersist(val as T) : (val as T)
localStorage.setItem(key, serialize(toStore))
}
} catch (e) {
console.error('useLocalStorageValue: localStorage write failed', e)
+79 -9
View File
@@ -73,8 +73,21 @@ export type UserDraftMeta = {
* The shape of what we actually persist. Wrapping the value lets us add
* metadata (timestamps, originating user, schema version, ...) later
* without breaking existing entries.
*
* `lastWrittenAt` is the unix-ms timestamp of the most recent write
* (setter call or deep mutation flush). It's the GC signal —
* `gcUserDrafts` sweeps entries that haven't been touched in N days.
* Set at every persist via `useLocalStorageValue`'s `transformBeforePersist`,
* `UserDraft.save`'s direct-write fallback, and `persistDirect`. Missing
* (undefined) on entries written before this field was introduced;
* `gcUserDrafts` backfills them on first sighting.
*/
type StoredDraft<V> = { value: V } & UserDraftMeta
type StoredDraft<V> = { value: V; lastWrittenAt?: number } & UserDraftMeta
function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefined {
if (stored === undefined) return undefined
return { ...stored, lastWrittenAt: Date.now() }
}
type DraftState<V> = { val: StoredDraft<V> | undefined }
@@ -162,7 +175,7 @@ export function checkStaleness(
*/
function persistDirect<V>(key: string, value: V | undefined, meta: UserDraftMeta): void {
try {
const next = wrap(value, meta)
const next = stamp(wrap(value, meta))
if (next === undefined) {
localStorage.removeItem(key)
} else {
@@ -251,7 +264,7 @@ export const UserDraft = {
try {
localStorage.setItem(
localStorageKey(ws, itemKind, path),
JSON.stringify(wrap(value, extractMeta(existing)))
JSON.stringify(stamp(wrap(value, extractMeta(existing))))
)
} catch (e) {
console.error('UserDraft.save: localStorage write failed', e)
@@ -444,17 +457,26 @@ function acquireEntry(
// reconcile. Wrap the creation in `$effect.root` so the entry's
// reactivity lives in its own scope and only the entry's release path
// disposes of it.
const useLocalStorageOptions = {
// The first value to flow into the handle (e.g. a backend load in
// the editor route) is the baseline — only persist when the user
// actually changes it afterwards. Coalesce a typing storm into one
// localStorage write per 500 ms.
saveInitialValue: false,
debounce: 500,
// Stamp every write with `lastWrittenAt` so the periodic GC pass
// can sweep entries that haven't been touched in 30 days. Done at
// persist time (not via `wrap()`) so deep mutations — which never
// re-run the setter — still bump the clock on each flush.
transformBeforePersist: stamp<unknown>
} as const
let stateRef: DraftState<unknown> | undefined
const destroyRoot = $effect.root(() => {
stateRef = useLocalStorageValue<StoredDraft<unknown> | undefined>(
localStorageKey(workspace, itemKind, path),
wrap(defaultValue),
undefined,
// The first value to flow into the handle (e.g. a backend load in
// the editor route) is the baseline — only persist when the user
// actually changes it afterwards. Coalesce a typing storm into one
// localStorage write per 500 ms.
{ saveInitialValue: false, debounce: 500 }
useLocalStorageOptions
)
})
if (stateRef) {
@@ -471,7 +493,7 @@ function acquireEntry(
localStorageKey(workspace, itemKind, path),
wrap(defaultValue),
undefined,
{ saveInitialValue: false, debounce: 500 }
useLocalStorageOptions
)
entries.set(mk, { count: 1, state })
}
@@ -533,6 +555,54 @@ function makeHandle<V>(
}
}
/**
* Default GC retention window: 30 days. Entries that haven't been touched
* (no setter call, no deep-mutation persist) for this long are swept on
* the next `gcUserDrafts` invocation.
*/
export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
/**
* Sweep stale UserDraft entries from localStorage. Walks every
* `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes
* any entry older than `maxAgeMs`.
*
* Entries written before `lastWrittenAt` was introduced lack the field;
* we backfill them to `now()` on first sighting so they participate in
* the next sweep cycle rather than getting wiped immediately.
*
* Safe to call on every load and on a timer (e.g. every 30 min) — live
* entries get their stamp refreshed on every persist, so the sweep only
* touches truly stale records.
*/
export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void {
if (typeof localStorage === 'undefined') return
const now = Date.now()
const cutoff = now - maxAgeMs
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k != null && k.startsWith('userdraft/w/')) keys.push(k)
}
for (const key of keys) {
try {
const raw = localStorage.getItem(key)
if (raw == null) continue
const parsed = JSON.parse(raw)
if (parsed == null || typeof parsed !== 'object') continue
if (typeof parsed.lastWrittenAt !== 'number') {
// Pre-GC-feature entry. Backfill so the next sweep can decide.
parsed.lastWrittenAt = now
localStorage.setItem(key, JSON.stringify(parsed))
continue
}
if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key)
} catch (e) {
console.error('UserDraft GC: failed to inspect', key, e)
}
}
}
/** Test-only: clear all in-memory entries. */
export function __resetUserDraftForTesting(): void {
entries.clear()
+96 -23
View File
@@ -37,6 +37,18 @@ function wrapped<V>(value: V): string {
return JSON.stringify({ value })
}
// Helper: read a localStorage entry, strip the GC `lastWrittenAt` stamp so
// assertions can stay focused on value + rev metadata. Real entries always
// carry `lastWrittenAt` once written; the GC tests below assert on it
// directly via `localStorage.getItem`.
function storedShape(key: string): string | null {
const raw = localStorage.getItem(key)
if (raw == null) return null
const parsed = JSON.parse(raw)
delete parsed.lastWrittenAt
return JSON.stringify(parsed)
}
beforeEach(() => {
__resetUserDraftForTesting()
onDestroyCallbacks.length = 0
@@ -49,8 +61,7 @@ describe('UserDraft.save / get / remove (no observers)', () => {
it('save writes a wrapped { value } payload under the workspace-scoped key', () => {
UserDraft.save('flow', 'u/me/myflow', { hello: 'world' })
const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/myflow')
expect(raw).toBe(wrapped({ hello: 'world' }))
expect(storedShape('userdraft/w/test_ws/flow/u/me/myflow')).toBe(wrapped({ hello: 'world' }))
})
it('get reads from a wrapped localStorage payload when no observer is registered', () => {
@@ -88,7 +99,7 @@ describe('UserDraft.save / get / remove (no observers)', () => {
it('uses the workspace from opts when provided', () => {
UserDraft.save('flow', 'u/me/f', 1, { workspace: 'other_ws' })
expect(localStorage.getItem('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1))
expect(storedShape('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1))
// Default workspace key must remain empty.
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/f')).toBeNull()
})
@@ -96,8 +107,9 @@ describe('UserDraft.save / get / remove (no observers)', () => {
it('supports trigger kinds as item kinds', () => {
UserDraft.save('trigger_kafka', 'u/me/topic1', { brokers: ['localhost:9092'] })
const raw = localStorage.getItem('userdraft/w/test_ws/trigger_kafka/u/me/topic1')
expect(raw).toBe(wrapped({ brokers: ['localhost:9092'] }))
expect(storedShape('userdraft/w/test_ws/trigger_kafka/u/me/topic1')).toBe(
wrapped({ brokers: ['localhost:9092'] })
)
})
it('throws when neither opts.workspace nor $workspaceStore is set', () => {
@@ -140,7 +152,7 @@ describe('UserDraft.use() — observer sync', () => {
UserDraft.save('flow', 'u/me/observed', 9)
expect(handle.draft).toBe(9)
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9))
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9))
})
it('remove() clears localStorage without touching the in-memory handle', () => {
@@ -168,7 +180,7 @@ describe('UserDraft.use() — observer sync', () => {
// Second (and onwards) persists.
handle.draft = 'persisted'
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted'))
expect(storedShape('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted'))
})
it('setting handle.draft = undefined after edits removes the localStorage entry', () => {
@@ -238,9 +250,7 @@ describe('UserDraft.use() — defaultValue', () => {
handle.draft = 'modified'
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(
wrapped('modified')
)
expect(storedShape('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(wrapped('modified'))
})
})
@@ -258,7 +268,7 @@ describe('UserDraft — empty path (new-item drafts persist across reloads)', ()
// The "+ Flow / + Script / …" buttons are expected to call
// `UserDraft.remove(kind, '')` to wipe before navigating; an
// unguarded /add reload therefore restores the previous session.
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBe(wrapped(100))
expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(100))
})
it('two handles with empty path share state per workspace', () => {
@@ -274,7 +284,7 @@ describe('UserDraft — empty path (new-item drafts persist across reloads)', ()
it('save() with empty path writes to localStorage when no handle is live', () => {
UserDraft.save('flow', '', 5)
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBe(wrapped(5))
expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(5))
})
it('get() with empty path falls back to localStorage when no handle is live', () => {
@@ -307,8 +317,7 @@ describe('UserDraft — rev metadata for staleness checks', () => {
// A subsequent user edit persists *with* the rev metadata.
handle.draft = 'userEdit'
flushPersist()
const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')
expect(raw).toBe(
expect(storedShape('userdraft/w/test_ws/flow/u/me/atomic')).toBe(
JSON.stringify({
value: 'userEdit',
remoteRev: 42,
@@ -326,7 +335,7 @@ describe('UserDraft — rev metadata for staleness checks', () => {
expect(handle.draft).toBe('edited')
expect(handle.meta).toEqual({ remoteRev: 2 })
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setmeta')).toBe(
expect(storedShape('userdraft/w/test_ws/flow/u/me/setmeta')).toBe(
JSON.stringify({ value: 'edited', remoteRev: 2 })
)
})
@@ -339,7 +348,7 @@ describe('UserDraft — rev metadata for staleness checks', () => {
expect(handle.meta).toEqual({ remoteRev: 'v1' })
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/preserve')).toBe(
expect(storedShape('userdraft/w/test_ws/flow/u/me/preserve')).toBe(
JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' })
)
})
@@ -366,7 +375,7 @@ describe('UserDraft — rev metadata for staleness checks', () => {
)
UserDraft.save('flow', 'u/me/savepreserve', 'new')
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe(
expect(storedShape('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe(
JSON.stringify({ value: 'new', remoteRev: 5 })
)
})
@@ -394,7 +403,7 @@ describe('UserDraft — rev metadata for staleness checks', () => {
handle.setMeta({ remoteRev: 'v2' }, { force: true })
expect(handle.meta).toEqual({ remoteRev: 'v2' })
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/forceack')).toBe(
expect(storedShape('userdraft/w/test_ws/flow/u/me/forceack')).toBe(
JSON.stringify({ value: 'edited', remoteRev: 'v2' })
)
})
@@ -465,7 +474,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => {
expect(a.draft).toBe(2)
// Now persisted (second write after the baseline).
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2))
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2))
// Releasing the second handle drops the entry; subsequent save()
// must go straight to localStorage rather than mutating in-memory
@@ -475,7 +484,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => {
UserDraft.save('flow', 'u/me/ref', 3)
// UserDraft.save without a live entry writes synchronously.
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3))
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3))
})
it('a fresh use() after cleanup re-reads the latest persisted value', () => {
@@ -509,7 +518,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => {
// After the window elapses, only the latest value lands.
vi.advanceTimersByTime(500)
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three'))
expect(storedShape('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three'))
})
})
@@ -530,10 +539,74 @@ describe('UserDraft.useMany()', () => {
handles[1].draft = 0
handles[1].draft = 9
flushPersist()
expect(localStorage.getItem('userdraft/w/a/flow/u/me/many')).toBe(wrapped(1))
expect(localStorage.getItem('userdraft/w/b/flow/u/me/many')).toBe(wrapped(9))
expect(storedShape('userdraft/w/a/flow/u/me/many')).toBe(wrapped(1))
expect(storedShape('userdraft/w/b/flow/u/me/many')).toBe(wrapped(9))
// One component-level onDestroy releases every acquired entry.
expect(onDestroyCallbacks.length).toBe(1)
})
})
describe('gcUserDrafts', () => {
let gcUserDrafts: (maxAgeMs?: number) => void
let USER_DRAFT_GC_MAX_AGE_MS: number
const DAY = 24 * 60 * 60 * 1000
beforeEach(async () => {
;({ gcUserDrafts, USER_DRAFT_GC_MAX_AGE_MS } = await import('./userDraft.svelte'))
})
it('sweeps entries whose lastWrittenAt is older than the cutoff', () => {
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
const old = Date.now() - 31 * DAY
const fresh = Date.now() - 1 * DAY
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/old',
JSON.stringify({ value: 1, lastWrittenAt: old })
)
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/fresh',
JSON.stringify({ value: 2, lastWrittenAt: fresh })
)
// Unrelated keys are left alone.
localStorage.setItem('some_other_key', 'unrelated')
gcUserDrafts()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/old')).toBeNull()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/fresh')).not.toBeNull()
expect(localStorage.getItem('some_other_key')).toBe('unrelated')
})
it('backfills lastWrittenAt on entries lacking it, instead of sweeping them immediately', () => {
// Pre-GC-feature entry (legacy migration output, or just an old entry
// from earlier in this PR's lifecycle): no `lastWrittenAt`. First GC
// pass should stamp it as "now" rather than wipe it on sight.
localStorage.setItem('userdraft/w/test_ws/flow/u/me/legacy', JSON.stringify({ value: 'data' }))
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
gcUserDrafts()
const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/legacy')
expect(raw).not.toBeNull()
const parsed = JSON.parse(raw!)
expect(parsed.lastWrittenAt).toBe(Date.now())
expect(parsed.value).toBe('data')
})
it('exposes a 30-day default retention window', () => {
expect(USER_DRAFT_GC_MAX_AGE_MS).toBe(30 * 24 * 60 * 60 * 1000)
})
it('respects a custom maxAgeMs', () => {
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/two_hours_ago',
JSON.stringify({ value: 1, lastWrittenAt: Date.now() - 2 * 60 * 60 * 1000 })
)
gcUserDrafts(60 * 60 * 1000) // 1h cutoff
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/two_hours_ago')).toBeNull()
})
})
@@ -12,6 +12,16 @@ function wrapped<V>(value: V): string {
return JSON.stringify({ value })
}
// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions
// can match the `{ value }` shape regardless of when the migration ran.
function storedShape(key: string): string | null {
const raw = localStorage.getItem(key)
if (raw == null) return null
const parsed = JSON.parse(raw)
delete parsed.lastWrittenAt
return JSON.stringify(parsed)
}
beforeEach(() => {
localStorage.clear()
__resetUserDraftLegacyMigrationForTesting()
@@ -30,7 +40,7 @@ describe('migrateLegacyUserDrafts', () => {
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app-u/me/dashboard')).toBeNull()
expect(localStorage.getItem('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp))
expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp))
})
it('migrates a legacy empty-path app draft (the `app` literal key)', () => {
@@ -40,7 +50,7 @@ describe('migrateLegacyUserDrafts', () => {
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app')).toBeNull()
expect(localStorage.getItem('userdraft/w/main/app/')).toBe(wrapped(legacyApp))
expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp))
})
it('migrates a legacy flow draft and strips the view-state envelope', () => {
@@ -59,7 +69,7 @@ describe('migrateLegacyUserDrafts', () => {
expect(localStorage.getItem('flow-u/me/myflow')).toBeNull()
// Only the inner Flow survives; the view-state envelope is dropped.
expect(localStorage.getItem('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow))
expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow))
})
it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => {
@@ -73,7 +83,7 @@ describe('migrateLegacyUserDrafts', () => {
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('rawapp-u/me/site')).toBeNull()
expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/site')).toBe(
expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe(
wrapped({ ...legacy, summary: '' })
)
})
+6 -1
View File
@@ -155,7 +155,12 @@ export function migrateLegacyUserDrafts(workspace: string): void {
const value = transformLegacyValue(match.newKind, decoded)
const target = newKey(workspace, match.newKind, match.path)
if (value !== undefined && localStorage.getItem(target) == null) {
localStorage.setItem(target, JSON.stringify({ value }))
// `lastWrittenAt` makes the migrated entry visible to
// `gcUserDrafts`. We stamp it as "now" so a freshly-migrated
// autosave gets the full retention window — sweeping it
// immediately on the first GC pass would lose work the
// legacy migration just rescued.
localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() }))
}
localStorage.removeItem(key)
} catch (e) {
@@ -59,6 +59,7 @@
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
import { gcUserDrafts } from '$lib/userDraft.svelte'
import { setContext, untrack } from 'svelte'
import { base } from '$app/paths'
import { Menubar } from '$lib/components/meltComponents'
@@ -422,6 +423,16 @@
$effect(() => {
if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!))
})
// Sweep UserDraft entries that haven't been touched in 30 days. Runs
// once on mount and on a 30-min timer so a single very long session
// also clears out stale autosaves over time. Live entries stamp
// `lastWrittenAt` on every persist, so the sweep only touches truly
// dormant records.
$effect(() => {
gcUserDrafts()
const interval = setInterval(() => gcUserDrafts(), 30 * 60 * 1000)
return () => clearInterval(interval)
})
$effect(() => {
innerWidth && untrack(() => changeCollapsed())
})