Files
supabase/apps/studio/state/sql-editor/sql-editor-lifecycle.ts
T
Charis 7c1ea30e43 refactor(studio): extract useSnippetEditor from MonacoEditor (#47500)
## What

PR 7 of the SQL editor state re-layering stack. Extracts the snippet
editing lifecycle out of `MonacoEditor` into a co-located
`useSnippetEditor` hook, and consolidates the edit debounce.

`useSnippetEditor` owns:
- creating the snippet in the store on first edit and routing to its URL
(replace vs push for a `?content=` deep link)
- writing changes back to the store via `setSql` (with
`wasNeverPersisted` → `shouldInvalidate`)
- seeding the editor from the `content` param
- the read-only determination (`canEditSnippet`)

`MonacoEditor` now consumes `{ snippet, disableEdit, handleEditorChange
}` and keeps only the editor shell + Monaco action wiring. It sheds the
`router`/`profile`/`project`/`params`/store/tabs hooks.

`handleEditorChange` was also flattened with an early return.

## Debounce consolidation

Previously there were **two 1s debounces in series**: `useSnippetEditor`
debounced editor changes before writing to the store, and the save
mechanism (`createSaveMechanism`) already debounces persistence. That
added latency (up to ~2s to save) and split the "when to persist" timing
policy across two layers — at odds with PR 5's design where the
scheduler/mechanism owns *when* and dirty state is meant to be
immediate.

This PR removes the editor-side debounce: edits write to the store
synchronously on every change, and the save mechanism's 1s debounce is
the sole throttle. Net effects:
- the store — and the snippet's dirty status — reflects the latest edit
immediately (correct for the future manual-save mode's Save button / nav
guard)
- save fires ~1s after the *last* keystroke instead of up to ~2s
- only the active snippet's own reactive consumers (a lightweight
sidebar item) re-render per keystroke; Monaco is uncontrolled
(`defaultValue`) so it is unaffected

Note: the double-debounce was legacy (the pre-refactor god store had the
same `useDebounce(value, 1000)` in MonacoEditor plus a debounced
module-load subscribe).

## Notes

- Behavior-preserving in outcome — autosave still lands ~1s after typing
stops, just with lower latency and immediate store consistency.

## Validation

- `pnpm --filter studio typecheck` ✅
- `pnpm exec vitest --run state/sql-editor/` ✅ (110 passed)
- lint ✅ (0 errors; no ratcheted-rule regressions)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* SQL editor changes now apply immediately, with unsaved status
reflected as soon as you edit.
* The editor now keeps the latest snippet details available for saving,
improving reliability when using “Save Query.”

* **Bug Fixes**
* Improved handling for creating and opening snippets from shared links
or prefilled content.
* Fixed status updates so saved snippets correctly switch to unsaved
after edits.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-01 15:59:15 -04:00

99 lines
4.0 KiB
TypeScript

import type { SnippetStatus } from '@/data/content/snippet-status'
/**
* True when the snippet has never been successfully written to the database.
* Gates content fetching and list invalidation, and marks locally-created
* snippets for the replication-lag 404 swallow in the `[id]` page.
*/
export function wasNeverPersisted(status: SnippetStatus | undefined): boolean {
return status === 'new' || status === 'new_saving' || status === 'new_save_failed'
}
/** True while a save request is in flight (whether first save or re-save). */
export function isSaving(status: SnippetStatus | undefined): boolean {
return status === 'new_saving' || status === 'saving'
}
/**
* True when the most recent save attempt failed (whether first save or
* re-save).
*/
export function isSaveFailed(status: SnippetStatus | undefined): boolean {
return status === 'new_save_failed' || status === 'save_failed'
}
/**
* True when the snippet holds changes that are not safely persisted: never
* saved ('new' family), a save in flight, a failed save, or pending local edits
* ('unsaved'). Used to warn before the tab is closed. Only 'saved' is clean.
*/
export function hasUnsavedChanges(status: SnippetStatus | undefined): boolean {
return status !== undefined && status !== 'saved'
}
/**
* Transition when a save request begins, preserving the never-persisted axis.
*/
export function statusOnSaveStart(status: SnippetStatus | undefined): SnippetStatus {
return wasNeverPersisted(status) ? 'new_saving' : 'saving'
}
/** Transition when a save succeeds — the snippet is now persisted and clean. */
export function statusOnSaveSuccess(): SnippetStatus {
return 'saved'
}
/** Transition when a save fails, preserving the never-persisted axis. */
export function statusOnSaveError(status: SnippetStatus | undefined): SnippetStatus {
return wasNeverPersisted(status) ? 'new_save_failed' : 'save_failed'
}
/**
* Transition when the snippet is edited locally. A persisted-and-clean snippet
* becomes 'unsaved' so its dirty state is durable immediately (rather than only
* once the debounced save begins). Every other status is already dirty or
* in-flight — the never-persisted family, a save in progress, or a failed
* save — and is left unchanged so the persistence and progress axes are
* preserved.
*/
export function statusOnEdit(status: SnippetStatus): SnippetStatus {
return status === 'saved' ? 'unsaved' : status
}
/**
* The lifecycle of a folder in the SQL editor nav, as a single set of
* mutually-exclusive states. Like SnippetStatus, this collapses two orthogonal
* axes — persistence (a locally-created placeholder vs a persisted folder) and
* progress (inline-name editing / save in flight / settled) — into one enum, so
* a folder can never be in a nonsensical combination (e.g. "new" yet "idle").
* The predicates below recover each axis.
*/
export type FolderStatus =
// Never persisted to the database (a locally-created placeholder):
| 'new_editing' // its name is being entered inline
| 'new_saving' // its create is in flight
// Persisted to the database:
| 'idle' // settled
| 'editing' // its name is being edited inline (rename)
| 'saving' // its rename is in flight
/** True for a locally-created placeholder folder that has not been persisted. */
export function isNewFolder(status: FolderStatus | undefined): boolean {
return status === 'new_editing' || status === 'new_saving'
}
/** True while the folder's name is being edited inline. */
export function isFolderEditing(status: FolderStatus | undefined): boolean {
return status === 'new_editing' || status === 'editing'
}
/** True while a folder create/rename is in flight. */
export function isFolderSaving(status: FolderStatus | undefined): boolean {
return status === 'new_saving' || status === 'saving'
}
/** Transition when a folder save begins, preserving the never-persisted axis. */
export function folderStatusOnSaveStart(status: FolderStatus): FolderStatus {
return isNewFolder(status) ? 'new_saving' : 'saving'
}