From 9af6e65df494fb2adb66a446c204101405e7f6fe Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 8 Jul 2026 12:32:11 +0800 Subject: [PATCH] fix(studio): DOM-nesting hydration errors, ghost deleted-snippet nav, and migrations query 400s (#47667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-level fixes that reproduce on BOTH the Next and TanStack builds — split out of #47657 (which stays TanStack-only) for reviewability. All were found by a full-site click-through of the dashboard. ## Invalid HTML nesting (React 19 "will cause a hydration error" console errors) - **FormLayout description rendered in a `

`** (`packages/ui-patterns`): consumers pass arbitrary JSX (the RowEditor's `created_at` timezone note passes a `

` with `

`s) → `

`-in-`

` / `

`-in-`

`. Container is now a `

` with identical classes (Tailwind preflight makes them render the same). - **Switch toggles nested inside Tooltip trigger buttons** (button-in-button) in ColumnEditor ("Allow Nullable" + "Is Unique"), ExtensionRow, and PublicationsTableItem → repo-standard `TooltipTrigger asChild` + `
` wrapper. - **Saved log queries rendered a `
` directly inside ``** (`/logs/explorer/saved`) → rows are now proper `` wrappers; the component itself is untouched (it's valid in its sidebar usage). - **Nested anchors in observability metric cards**: a card-level `` wrapped MetricCard's "More information" `` (identical URLs) → the chevron affordance renders as a `` when no `href` is passed; clicks bubble to the card link, tooltips preserved. Design-system standalone usage unaffected. - **`objectFit="cover"` passed to modern `next/image`** on the featured integration card (unknown-prop warning) — the className already had `object-cover`; prop dropped. ## Ghost dead-snippet after deletion Deleting the active SQL snippet left its id in `useDashboardHistory` (`history.sql`), so the "SQL Editor" nav item navigated to `/sql/` — content fetch 404s, no editor pane renders, and a phantom tab reappears. Fixed both ends: delete flows now purge dashboard history (and the tabs store clears a stale `previewTabId`), and `/sql/[id]` treats a snippet 404 as "clean up + `router.replace` to `/sql/new` + toast" instead of rendering the dead state. Unit tests for the store/history cleanup. ## `pg-meta` migrations query 400s on every project load `ActivityStats` on project home runs the migrations list query, whose SQL was a bare `select * from supabase_migrations.schema_migrations` — that table only exists once a migration has run, so every other project logged a failed `?key=migrations` request on every load (visible in production consoles too). The SQL is now guarded with `to_regclass` + `query_to_xml` (same pattern as the advisor lints' `storage.buckets` guard), returning zero rows instead of erroring; legacy version-only tables still work. Tested against real dockerized Postgres (absent table, populated ordering, special chars, legacy schema) + MSW hook tests. Found and verified via /test-supabase-local (browser click-through + console audit on both builds). ## To test Console must stay free of React DOM-nesting errors ("cannot be a descendant of" / "cannot contain a nested") on each surface: 1. Table editor → Insert row panel (`created_at` field renders its timezone note) and Edit column panel ("Allow Nullable"/"Is Unique" tooltips still hover). 2. `/database/extensions` and `/database/publications` → toggle switches render, tooltips hover. 3. `/logs/explorer/saved` (with ≥1 saved query) → rows render full-width inside the table, hover shows Actions. 4. `/observability` → no nested-anchor error on load; card body click and the chevron both navigate; label help-icons still show tooltips. 5. `/integrations` → no `objectFit` unknown-prop warning; featured card images still cover. 6. **Ghost snippet**: open a SQL snippet → delete it via the sidebar → click the "SQL Editor" nav item → lands on `/sql/new` (no phantom tab, no 404 content fetch). Direct-load `/sql/` → toast + redirect to `/sql/new`. 7. **Migrations 400**: load project home with a project that has never run a migration → the `pg-meta//query?key=migrations` request returns **200** with `[]` (previously a 400 on every load). Database → Migrations still lists real migrations when they exist. ## Summary by CodeRabbit ## Summary by CodeRabbit * **Bug Fixes** * Deleted SQL snippets are fully removed from dashboard history and stale editor/tab state; users are redirected with a toast. * Closing preview tabs no longer leaves stale references. * Improved toggle/tooltip/dialog interactions to avoid broken UI, including metric headers showing tooltips even without direct links. * Migrations display safely when migration tables/relations are missing. * **UI Improvements** * Refreshed layout for saved queries, form descriptions, and integration imagery. * **Tests** * Added coverage for snippet history cleanup, tab removal, migrations SQL behavior, and query edge cases. --- ### Review feedback: `query_to_xml` breaks on Multigres (Ivan) The defensive migrations query (added here to stop the `?key=migrations` 400 when the table doesn't exist yet) originally guarded with `query_to_xml`, which is forbidden through Multigres's pooler (MUL-736 / PSQL-1318). Rewritten without `query_to_xml`/`xmltable` using the splinter#170 pattern: a PL/pgSQL `do` block guarded by `to_regclass` (PL/pgSQL defers planning, so a missing table never errors) stashes the rows into a transaction-local GUC via `set_config`, and a trailing `select` reads them back with `jsonb_array_elements`. Verified that postgres-meta sends the whole SQL as one simple-query string → single implicit transaction → the local GUC survives to the `select` and doesn't leak into the pooled connection. 6/6 dockerized-Postgres tests (absent table → `[]`, populated/ordered/special-chars, legacy version-only table, full pg-meta-shaped multi-statement string, GUC non-leakage). Note (out of scope, pre-existing): `packages/pg-meta/src/sql/studio/advisor/lints.ts` still uses `query_to_xml` — a separate pre-existing Multigres risk that should get its own splinter-pattern sync. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Joshen Lim Co-authored-by: Saxon Fletcher --- .../interfaces/Auth/Users/UsersV2.tsx | 1 + .../Database/Extensions/ExtensionRow.tsx | 19 ++- .../Publications/PublicationsTableItem.tsx | 27 +-- .../Database/Roles/CreateRolePanel.tsx | 1 + .../CronJobs/CronJobTableCell.tsx | 32 ++-- .../CronJobs/CronJobs.constants.tsx | 2 +- .../Integrations/Landing/IntegrationCard.tsx | 1 - .../DatabaseInfrastructureSection.tsx | 15 +- .../Subscription/ExitSurveyModal.tsx | 2 +- .../ColumnEditor/ColumnEditor.tsx | 77 +++++---- .../SQLEditorNavV2/DeleteSnippetsModal.tsx | 6 + .../SQLEditorNavV2/SQLEditorNav.tsx | 6 + .../data/database/migrations-query.test.ts | 63 +++++++ .../hooks/misc/useDashboardHistory.test.tsx | 67 ++++++++ apps/studio/hooks/misc/useDashboardHistory.ts | 13 ++ .../project/[ref]/logs/explorer/saved.tsx | 6 +- apps/studio/pages/project/[ref]/sql/[id].tsx | 29 +++- apps/studio/state/tabs.test.ts | 67 ++++++++ apps/studio/state/tabs.tsx | 6 + .../src/sql/studio/database/migrations.ts | 48 +++++- packages/pg-meta/test/db/utils.ts | 13 +- .../test/sql/studio/migrations.test.ts | 155 ++++++++++++++++++ packages/ui-patterns/src/MetricCard/index.tsx | 23 ++- .../src/form/Layout/FormLayout.tsx | 6 +- 24 files changed, 592 insertions(+), 93 deletions(-) create mode 100644 apps/studio/data/database/migrations-query.test.ts create mode 100644 apps/studio/hooks/misc/useDashboardHistory.test.tsx create mode 100644 packages/pg-meta/test/sql/studio/migrations.test.ts diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 71adece92db..76624b3b31b 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -807,6 +807,7 @@ export const UsersV2 = () => { renderRow(id, props) { return ( { const user = users.find((u) => u.id === id) diff --git a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx index 4c68836985a..55123bf7d7b 100644 --- a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx @@ -158,14 +158,17 @@ export const ExtensionRow = ({ extension }: ExtensionRowProps) => { ) : ( - - - isOn ? setIsDisableModalOpen(true) : setShowConfirmEnableModal(true) - } - /> + +
+ + isOn ? setIsDisableModalOpen(true) : setShowConfirmEnableModal(true) + } + /> +
{disabled && ( diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx index 5ef3ea67a85..18be132aaae 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx @@ -25,7 +25,7 @@ export const PublicationsTableItem = ({ const isProtected = protectedSchemas.map((x) => x.name).includes(table.schema) const [checked, setChecked] = useState( - selectedPublication.tables?.find((x: any) => x.id == table.id) != undefined + selectedPublication.tables?.find((x) => x.id == table.id) != undefined ) const { can: canUpdatePublications } = useAsyncCheckPermissions( @@ -42,14 +42,12 @@ export const PublicationsTableItem = ({ setChecked(!checked) const publicationTables = publication?.tables ?? [] - const exists = publicationTables.some((x: any) => x.id == table.id) + const exists = publicationTables.some((x) => x.id == table.id) const tables = !exists ? [`${table.schema}.${table.name}`].concat( - publicationTables.map((t: any) => `${t.schema}.${t.name}`) + publicationTables.map((t) => `${t.schema}.${t.name}`) ) - : publicationTables - .filter((x: any) => x.id != table.id) - .map((x: any) => `${x.schema}.${x.name}`) + : publicationTables.filter((x) => x.id != table.id).map((x) => `${x.schema}.${x.name}`) updatePublications( { @@ -87,13 +85,16 @@ export const PublicationsTableItem = ({ ) : ( - - toggleReplicationForTable(table, selectedPublication)} - /> + +
+ toggleReplicationForTable(table, selectedPublication)} + /> +
{isProtected && ( diff --git a/apps/studio/components/interfaces/Database/Roles/CreateRolePanel.tsx b/apps/studio/components/interfaces/Database/Roles/CreateRolePanel.tsx index b8a8d9b888a..a06475194af 100644 --- a/apps/studio/components/interfaces/Database/Roles/CreateRolePanel.tsx +++ b/apps/studio/components/interfaces/Database/Roles/CreateRolePanel.tsx @@ -51,6 +51,7 @@ export const CreateRolePanel = ({ visible, onClose }: CreateRolePanelProps) => { const form = useForm>({ resolver: zodResolver(FormSchema), + defaultValues: initialValues, }) const { mutate: createDatabaseRole, isPending: isCreating } = useDatabaseRoleCreateMutation({ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx index bacefa744fc..499697b15e5 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx @@ -38,8 +38,9 @@ import { import { CodeBlock } from 'ui-patterns/CodeBlock' import { TimestampInfo } from 'ui-patterns/TimestampInfo' +import { type CronTableColumn } from './CronJobs.constants' import { useDatabaseCronJobRunCommandMutation } from '@/data/database-cron-jobs/database-cron-job-run-mutation' -import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query' +import { type CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query' import { useDatabaseCronJobToggleMutation } from '@/data/database-cron-jobs/database-cron-jobs-toggle-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -75,8 +76,8 @@ const getNextRun = (schedule: string, lastRun?: string) => { } interface CronJobTableCellProps { - col: any - row: any + col: CronTableColumn + row: CronJob onSelectEdit: (job: CronJob) => void onSelectDelete: (job: CronJob) => void } @@ -92,19 +93,20 @@ export const CronJobTableCell = ({ const [showToggleModal, setShowToggleModal] = useState(false) - const value = row?.[col.id] + const value = row?.[col.id as keyof typeof row] const { jobid, schedule, latest_run, status, active, jobname } = row - const formattedValue = + const formattedValue = ( col.id === 'jobname' && !jobname ? 'No name provided' : col.id === 'lastest_run' ? !!value - ? dayjs(value).valueOf() + ? dayjs(value as string).valueOf() : undefined : col.id === 'next_run' ? getNextRun(schedule, latest_run) : value + ) as string const hasValue = col.id === 'next_run' ? !!formattedValue : col.id in row @@ -148,6 +150,7 @@ export const CronJobTableCell = ({
{linkTooltip ? {linkTooltip} : null} diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx index 338daf0b1bb..b6db8bcaec2 100644 --- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx +++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx @@ -318,12 +318,14 @@ export const FormLayout = React.forwardRef< {description} ) : description ? ( -

{description} -

+
) : null const LabelContents = () => (