From 644fe0821bed9ab55cb866de8980df70a0ee9b57 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 9 Jul 2026 18:18:08 +0800 Subject: [PATCH 01/12] Add create org CTA for authorize route if no org found (#47760) ## Context As per PR title - also left a comment that this is a short term solution for now, so we know where to clean up after the long term solution is implemented ## Summary by CodeRabbit - **New Features** - Added a clear action in the empty organizations state so users can create an organization directly from the authorization flow. - **Bug Fixes** - Improved authorization error messaging for clearer, more consistent display. - Refined invalid authorization guidance so the retry prompt and missing-parameter details are shown more cleanly. --- .../ApiAuthorization/ApiAuthorization.Error.tsx | 10 ++++------ .../ApiAuthorization/ApiAuthorization.Form.tsx | 7 +++++++ .../ApiAuthorization/ApiAuthorization.Invalid.tsx | 3 ++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx index 26a0b03bd9896..1789962add40d 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx @@ -18,13 +18,11 @@ export function ApiAuthorizationErrorScreen({
- Retry the authorization request from the requesting app. - {error && ( - Error: {error.message} - )} - + error && ( + Error: {error.message} + ) } /> , + ]} /> ) } diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx index df6e42f282ed8..66c2f0c7c519c 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx @@ -23,7 +23,8 @@ export function ApiAuthorizationInvalidScreen({
From f34fdd6c8fba66c21a7842fc863dd4e6d1af4fbc Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 9 Jul 2026 18:19:39 +0800 Subject: [PATCH 02/12] Skip using count estimate function for retrieving row counts if in read only context (#47761) ## Context Currently when retrieving row counts of a table in the Table Editor, we're using a `COUNT_ESTIMATE` pg function ([ref](https://github.com/supabase/supabase/blob/master/packages/pg-meta/src/sql/studio/database/get-count-estimate.ts#L5)) to retrieve an estimate (instead of checking `pg_class` -> `reltuples`) as that would theoretically provide a more accurate representation. However, in a read only context, that function can't be used - users will run into `cannot execute CREATE FUNCTION in a read-only transaction`, so we need to fallback to just checking `pg_class` in this scenario. The logic's already set up as we were previously looking into allowing users to use a read replica to power the dashboard, but we also need to consider members with read-only roles within the organization, so this PR updates the logic a little to factor that in. ## To test - [ ] With a read-only role, open the table editor and verify that we're not using the count estimate function to retrieve the table row counts ## Summary by CodeRabbit * **New Features** * Updated the invite member dialog to open in a larger size for better usability. * **Bug Fixes** * Improved table row count behavior so it now respects read-only access and permission limits more reliably. * Count estimates should now be shown more consistently across different database contexts. --- .../TeamSettings/InviteMemberButton.tsx | 2 +- .../data/table-rows/table-rows-count-query.ts | 14 ++++++++++---- packages/pg-meta/src/sql/studio/database/rows.ts | 7 ++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 140fb26a64fae..c5d1fab5de4fd 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -280,7 +280,7 @@ export const InviteMemberButton = () => { - + Invite team members diff --git a/apps/studio/data/table-rows/table-rows-count-query.ts b/apps/studio/data/table-rows/table-rows-count-query.ts index 3933afd0c73d2..111be34e74cbb 100644 --- a/apps/studio/data/table-rows/table-rows-count-query.ts +++ b/apps/studio/data/table-rows/table-rows-count-query.ts @@ -1,4 +1,5 @@ import { getTableRowsCountSql } from '@supabase/pg-meta' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query' import { IS_PLATFORM } from 'common' @@ -9,6 +10,7 @@ import type { Filter, SupaTable } from '@/components/grid/types' import { useConnectionStringForReadOps } from '@/data/read-replicas/replicas-query' import { executeSql } from '@/data/sql/execute-sql-mutation' import { prefetchTableEditor } from '@/data/table-editor/table-editor-query' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { RoleImpersonationState, wrapWithRoleImpersonation } from '@/lib/role-impersonation' import { isRoleImpersonationEnabled } from '@/state/role-impersonation-state' import { ResponseError, UseCustomQueryOptions } from '@/types' @@ -44,8 +46,8 @@ export async function getTableRowsCount( filters, roleImpersonationState, enforceExactCount, - isUsingReadReplica = false, - }: TableRowsCountVariables & { isUsingReadReplica?: boolean }, + isReadOnlyContext = false, + }: TableRowsCountVariables & { isReadOnlyContext?: boolean }, signal?: AbortSignal ) { const entity = await prefetchTableEditor(queryClient, { @@ -65,7 +67,7 @@ export async function getTableRowsCount( table, filters: formattedFilters, enforceExactCount, - isUsingReadReplica, + isReadOnlyContext, }), roleImpersonationState ) @@ -103,6 +105,10 @@ export const useTableRowsCountQuery = ( identifier: readReplicaIdentifier, type, } = useConnectionStringForReadOps() + const { can: canSQLAdminWrite } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'tables' + ) return useQuery({ queryKey: tableRowKeys.tableRowsCount(projectRef, { @@ -117,7 +123,7 @@ export const useTableRowsCountQuery = ( projectRef, connectionString, tableId, - isUsingReadReplica: type === 'replica', + isReadOnlyContext: type === 'replica' || !canSQLAdminWrite, ...args, }, signal diff --git a/packages/pg-meta/src/sql/studio/database/rows.ts b/packages/pg-meta/src/sql/studio/database/rows.ts index c4657bbe2a082..ad6e751745353 100644 --- a/packages/pg-meta/src/sql/studio/database/rows.ts +++ b/packages/pg-meta/src/sql/studio/database/rows.ts @@ -12,12 +12,13 @@ export const getTableRowsCountSql = ({ table, filters = [], enforceExactCount = false, - isUsingReadReplica = false, + isReadOnlyContext = false, }: { table: any filters?: Filter[] enforceExactCount?: boolean - isUsingReadReplica?: boolean + /** Skips using the count estimate function if true and fallsback to checking reltuples from pg_class */ + isReadOnlyContext?: boolean }): SafeSqlFragment => { if (!table) return safeSql`` @@ -59,7 +60,7 @@ export const getTableRowsCountSql = ({ ? (countBaseSql.slice(0, -1) as SafeSqlFragment) : countBaseSql - if (isUsingReadReplica) { + if (isReadOnlyContext) { const sql = safeSql` with approximation as ( select reltuples as estimate From 74bc0a8e270da89c8a43d612d2807e2869659254 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 9 Jul 2026 18:41:03 +0800 Subject: [PATCH 03/12] fix(studio): initialize Sentry on the TanStack build (captures were silent no-ops) (#47666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #47657 (base is `alaister/tanstack-migration-fixes`; retarget to `master` once that merges). The TanStack runtime never ran `Sentry.init` — `instrumentation-client.ts` is a Next-convention file nothing imports under TanStack Start, so every `Sentry.captureException` on that build (including the `routes/__root.tsx` error-boundary / `routerErrorComponent` reports) was a silent no-op. - **Shared config source**: the entire client config moves verbatim from `instrumentation-client.ts` into `lib/sentry-client-options.ts` (`buildSentryClientOptions`). Both runtimes build from it, so Next and TanStack can't drift — the builds differ only in two explicit knobs. - **TanStack init**: `sentry.tanstack.ts` initializes `@sentry/react` from `getRouter()` (TanStack Start's real client bootstrap — the earliest point with the router instance), wiring `tanstackRouterBrowserTracingIntegration(router)`. Window-guarded + idempotent; `router.tsx` is TanStack-only so the Next build is untouched. (Named without `.client.` — Start's import-protection fails the build for `*.client.*` in the server graph.) - **Third-party error filter is intentionally Next-only**: without the bundler-injected `applicationKey` metadata (only `withSentryConfig` provides it), the SDK tags *every* event `third_party_code: true` and `beforeSend` would drop them all — recreating the silent no-op with a DSN set. Follow-up: add `@sentry/vite-plugin` moduleMetadata, then enable. - **DSN-less builds stay crash-free**: `vite.config.ts` inlines `undefined` for unset `NEXT_PUBLIC_SENTRY_DSN`/`NEXT_PUBLIC_SENTRY_ENVIRONMENT` (a literal `process.env.*` in the bundle is the exact `process is not defined` class #47657 fixed). No-DSN → disabled client, plus the existing `IS_PLATFORM`/consent gates. - Tests: `instrumentation-client.test.ts` moved to `lib/sentry-client-options.test.ts` with all 36 assertions kept, plus integration-gating and Next/TanStack parity tests. `tsc` clean; full `vite build --mode test` passes. Follow-up (separate): server-side Sentry for the Start handler (`server.ts` entry + `@sentry/node`-style init). ## To test - **Locally (no DSN set)**: load the TanStack build — no Sentry network requests, no console errors, and crucially no `ReferenceError: process is not defined` (the define fallback). Forcing an error must not POST to any `/envelope` endpoint. - **On a preview/deploy (DSN set, telemetry consent accepted)**: throw a test error (e.g. crash a route component) → a POST to `o…ingest.sentry.io/api/…/envelope/` fires, and the event lands in Sentry with a `codeSampleRate` tag and **no** `third_party_code` tag. Navigation spans named after TanStack routes appear when the 2% pageload trace samples in. - **Next build regression check**: the Next dev/preview still reports errors exactly as before (`instrumentation-client.ts` now builds its options from the same shared source). --- ### Review feedback: Sentry `/envelope` never fires on TanStack (Joshen) Root-caused: `@sentry/core`'s `Client.sendSession` silently drops the session when the client has no `release`. The Next build gets a release injected by `withSentryConfig` (the Vercel commit SHA); the Vite build runs no Sentry bundler plugin, so it had no release → session envelopes were discarded before transport → zero `/envelope` traffic (errors/transactions are separate). Fix: inject `release: NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA` on the TanStack build (vite.config re-exposes `VERCEL_GIT_COMMIT_SHA` under the `NEXT_PUBLIC_` name, same SHA the Next release resolves to). Also switched `integrations` to the function form so defaults are preserved by contract (not just by current SDK behavior). 45 unit tests green. **To test (deploys only — the SHA is unset locally, so this can't be reproduced on a local dev build):** on this PR's Vercel preview with a DSN + telemetry consent, load any page and watch the Network tab for a POST to `…ingest.sentry.io/…/envelope/` — a session envelope should now fire on load, matching the Next build. ## Summary by CodeRabbit * **New Features** * Improved client-side error and performance monitoring for the Studio app across both router setups. * Added support for passing release/version information into monitoring data. * **Bug Fixes** * Reduced noisy error reporting by better filtering common browser, extension, cancellation, and load-related issues. * Prevented browser bundles from referencing missing environment values at runtime. * Made monitoring initialization safer in server-rendered and client-only environments. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../studio/.github/eslint-rule-baselines.json | 4 +- apps/studio/instrumentation-client.ts | 318 +-------------- .../sentry-client-options.test.ts} | 82 +++- apps/studio/lib/sentry-client-options.ts | 368 ++++++++++++++++++ apps/studio/router.tsx | 7 + apps/studio/sentry.tanstack.test.ts | 136 +++++++ apps/studio/sentry.tanstack.ts | 66 ++++ apps/studio/vite.config.ts | 19 + 8 files changed, 691 insertions(+), 309 deletions(-) rename apps/studio/{instrumentation-client.test.ts => lib/sentry-client-options.test.ts} (77%) create mode 100644 apps/studio/lib/sentry-client-options.ts create mode 100644 apps/studio/sentry.tanstack.test.ts create mode 100644 apps/studio/sentry.tanstack.ts diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 5926095fa2105..3257114252d35 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -3,7 +3,7 @@ "react-hooks/exhaustive-deps": 161, "import/no-anonymous-default-export": 57, "@tanstack/query/exhaustive-deps": 9, - "@typescript-eslint/no-explicit-any": 898, + "@typescript-eslint/no-explicit-any": 897, "no-restricted-imports": 0, "no-restricted-exports": 198, "react/no-unstable-nested-components": 38, @@ -556,7 +556,6 @@ "hooks/misc/withAuth.tsx": 1, "hooks/ui/useClickedOutside.ts": 2, "hooks/ui/useFlag.ts": 1, - "instrumentation-client.ts": 3, "lib/ai/generate-assistant-response.ts": 2, "lib/ai/model.ts": 2, "lib/ai/model.utils.ts": 3, @@ -571,6 +570,7 @@ "lib/pg-format.ts": 6, "lib/profile.tsx": 1, "lib/role-impersonation.ts": 1, + "lib/sentry-client-options.ts": 2, "lib/telemetry/track.ts": 1, "pages/_app.tsx": 1, "pages/api/ai/feedback/rate.ts": 2, diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index 0097516a418c6..132068af7e900 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -1,314 +1,22 @@ -// This file configures the initialization of Sentry on the client. -// The config you add here will be used whenever a user loads a page in their browser. +// This file configures the initialization of Sentry on the client for the +// NEXT build — Next auto-loads it whenever a user loads a page in their +// browser. The TanStack Start (Vite) build never loads Next convention files; +// it initializes Sentry with the same shared options in +// sentry.tanstack.ts instead. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from '@sentry/nextjs' -import { hasConsented } from 'common' -import { IS_PLATFORM } from 'common/constants/environment' -import { MIRRORED_BREADCRUMBS } from '@/lib/breadcrumbs' -import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from '@/lib/sanitize' +import { buildSentryClientOptions } from '@/lib/sentry-client-options' -const DEFAULT_ERROR_SAMPLE_RATE = 1.0 -const LOW_PRIORITY_ERROR_SAMPLE_RATE = 0.01 -const CHUNK_LOAD_ERROR_PATTERNS = [ - /ChunkLoadError/i, - /Loading chunk [\d]+ failed/i, - /Loading CSS chunk [\d]+ failed/i, -] - -// This is a workaround to ignore hCaptcha related errors. -function isHCaptchaRelatedError(event: Sentry.Event): boolean { - const errors = event.exception?.values ?? [] - for (const error of errors) { - if ( - error.value?.includes('is not a function') && - error.stacktrace?.frames?.some((f) => f.filename === 'api.js') - ) { - return true - } - } - return false -} - -// Filter browser wallet extension errors (e.g., Gate.io wallet) -// These errors come from injected wallet scripts and are not actionable -// Examples: SUPABASE-APP-AFC, SUPABASE-APP-92A -export function isBrowserWalletExtensionError(event: Sentry.Event): boolean { - const frames = event.exception?.values?.flatMap((e) => e.stacktrace?.frames || []) || [] - return frames.some((frame) => { - const filename = frame.filename || frame.abs_path || '' - return filename.includes('gt-window-provider') || filename.includes('wallet-provider') +Sentry.init( + buildSentryClientOptions({ + // next.config.ts (withSentryConfig) annotates the bundles with the + // 'supabase-studio' applicationKey, so third-party frame tagging works + // on this build. + includeThirdPartyErrorFilter: true, }) -} - -// Filter user-aborted operations (intentional cancellations) -// These are expected when users cancel requests or navigate away -// Examples: SUPABASE-APP-BG6, SUPABASE-APP-BG7 -export function isUserAbortedOperation(error: unknown, event: Sentry.Event): boolean { - const errorMessage = error instanceof Error ? error.message : '' - const eventMessage = event.message || '' - const message = errorMessage || eventMessage - - return ( - message.includes('operation was aborted') || - message.includes('signal is aborted') || - message.includes('manually canceled') || - message.includes('AbortError') - ) -} - -// Filter cancellation promise rejections (e.g., from query cancellation) -// These occur when operations are intentionally cancelled by the user -// Example: SUPABASE-APP-353 (~466k events) -export function isCancellationRejection(event: Sentry.Event): boolean { - const serialized = event.extra?.__serialized__ as Record | undefined - return serialized?.type === 'cancelation' -} - -// Filter challenge/captcha expired errors (user timeout) -// These happen when users don't complete captcha in time - expected behavior -// Example: SUPABASE-APP-ACC -export function isChallengeExpiredError(error: unknown, event: Sentry.Event): boolean { - const errorMessage = error instanceof Error ? error.message : '' - const eventMessage = event.message || '' - const message = errorMessage || eventMessage - - return message.includes('challenge-expired') -} - -function isChunkLoadError(error: unknown, event: Sentry.Event): boolean { - const errorMessage = error instanceof Error ? error.message : '' - const eventMessage = event.message || '' - const exceptionMessages = event.exception?.values?.map((ex) => ex.value ?? '') ?? [] - const combinedMessages = [errorMessage, eventMessage, ...exceptionMessages].filter(Boolean) - - return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => - combinedMessages.some((message) => pattern.test(message)) - ) -} - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - ...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && { - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, - }), - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - // Enable performance monitoring - tracesSampleRate: 0.02, - - integrations: (() => { - const thirdPartyErrorFilterIntegration = (Sentry as any).thirdPartyErrorFilterIntegration - if (!thirdPartyErrorFilterIntegration) return [] - - // Tag errors whose stack trace only contains third-party frames (browser extensions, - // injected scripts, etc.). This uses build-time code annotation via the applicationKey - // in next.config.ts to reliably distinguish our code from third-party code. - // We use 'apply-tag' instead of 'drop' so that beforeSend can exempt error boundary - // crashes — these may originate in third-party code but are caused by first-party bugs. - return [ - thirdPartyErrorFilterIntegration({ - filterKeys: ['supabase-studio'], - behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', - }), - ] - })(), - - // Only capture errors originating from our own code. - // This is a whitelist on the source URL in stack frames — it drops errors from - // browser extensions, injected scripts, third-party widgets, etc. (FE-2094) - allowUrls: [ - /https?:\/\/(.*\.)?supabase\.(com|co|green|io)/, - /app:\/\//, // Next.js rewrites source URLs to app:// with source maps - ], - beforeBreadcrumb(breadcrumb, _hint) { - const cleanedBreadcrumb = { ...breadcrumb } - - if (cleanedBreadcrumb.category === 'navigation') { - if (typeof cleanedBreadcrumb.data?.from === 'string') { - cleanedBreadcrumb.data.from = sanitizeUrlHashParams(cleanedBreadcrumb.data.from) - } - if (typeof cleanedBreadcrumb.data?.to === 'string') { - cleanedBreadcrumb.data.to = sanitizeUrlHashParams(cleanedBreadcrumb.data.to) - } - } - - MIRRORED_BREADCRUMBS.pushBack(cleanedBreadcrumb) - return cleanedBreadcrumb - }, - beforeSend(event, hint) { - const consent = hasConsented() - - if (!consent) { - return null - } - - if (!IS_PLATFORM) { - return null - } - - const isErrorBoundaryCrash = - event.tags?.globalErrorBoundary === true || event.tags?.globalErrorBoundary === 'true' - const isThirdPartyOnly = - event.tags?.third_party_code === true || event.tags?.third_party_code === 'true' - - // Drop third-party-only errors UNLESS they crashed the page via the global error boundary. - // This preserves noise reduction for browser extensions and injected scripts, - // while ensuring page-crashing errors from third-party libs (caused by first-party bugs) - // are always reported. - if (isThirdPartyOnly && !isErrorBoundaryCrash) { - return null - } - - // Downsample only known high-noise classes; keep all other errors at full rate. - const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes( - `Failed to construct 'URL': Invalid URL` - ) - const isSessionTimeoutEvent = (hint.originalException as any)?.message?.includes( - 'Session error detected' - ) - const isChunkLoadFailure = isChunkLoadError(hint.originalException, event) - - const codeSampleRate = - isInvalidUrlEvent || isSessionTimeoutEvent || isChunkLoadFailure - ? LOW_PRIORITY_ERROR_SAMPLE_RATE - : DEFAULT_ERROR_SAMPLE_RATE - - if (Math.random() > codeSampleRate) { - return null - } - - event.tags = { - ...event.tags, - codeSampleRate: codeSampleRate.toString(), - } - - if (isHCaptchaRelatedError(event)) { - return null - } - - // Drop events where every exception has no stack trace — these are not debuggable. - // Exempt error boundary crashes: even without stack frames, a page crash is always worth reporting. - const exceptions = event.exception?.values ?? [] - if ( - !isErrorBoundaryCrash && - exceptions.length > 0 && - exceptions.every((ex) => !ex.stacktrace?.frames?.length) - ) { - return null - } - - // Filter out errors like 'e._5BLbSXV[t] is not a function' or anything matching '[t] is not a function' - if ( - hint.originalException instanceof Error && - hint.originalException.message.includes('[t] is not a function') - ) { - return null - } - - if (isBrowserWalletExtensionError(event)) { - return null - } - if (isUserAbortedOperation(hint.originalException, event)) { - return null - } - if (isCancellationRejection(event)) { - return null - } - if (isChallengeExpiredError(hint.originalException, event)) { - return null - } - - if (event.breadcrumbs) { - event.breadcrumbs = sanitizeArrayOfObjects(event.breadcrumbs) as Sentry.Breadcrumb[] - } - return event - }, - ignoreErrors: [ - // === Monaco Editor === - 'ResizeObserver', - 's.getModifierState is not a function', - /^Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'/, - - // === Browser extension errors === - // Gate.io wallet - 'shouldSetTallyForCurrentProvider is not a function', - // SAP browser extensions (SAP GUI, SAP Companion) - 'sap is not defined', - // Non-Error objects thrown as exceptions (e.g., Event objects) - '[object Event]', - - // === Third-party SDK errors === - // stripe-js: https://github.com/stripe/stripe-js/issues/26 - 'Failed to load Stripe.js', - // hCaptcha - "undefined is not an object (evaluating 'n.chat.setReady')", - "undefined is not an object (evaluating 'i.chat.setReady')", - - // === Next.js internals === - // Ref: https://github.com/supabase/supabase/pull/9729 - /The provided `href` \(\/org\/\[slug\]\/.*\) value is missing query values/, - // Next.js throws these during navigation, not actual errors - 'NEXT_NOT_FOUND', - 'NEXT_REDIRECT', - - // === User input errors (not bugs) === - // sql-formatter lexer on invalid SQL input - /^Parse error: Unexpected ".+" at line \d+ column \d+$/, - - // === Network / infrastructure (not actionable on FE) === - /504 Gateway Time-out/, - 'Network request failed', - 'Failed to fetch', - 'Load failed', - 'AbortError', - 'TypeError: cancelled', - 'TypeError: Cancelled', - - // === Browser extensions & Google Translate DOM manipulation === - 'Node.insertBefore: Child to insert before is not a child of this node', - 'Node.removeChild: The node to be removed is not a child of this node', - "NotFoundError: Failed to execute 'removeChild' on 'Node'", - "NotFoundError: Failed to execute 'insertBefore' on 'Node'", - 'NotFoundError: The object can not be found here.', - "Cannot read properties of null (reading 'parentNode')", - "Cannot read properties of null (reading 'removeChild')", - "TypeError: can't access dead object", - /^NS_ERROR_/, - - // === Non-Error throws (extensions, third-party libs throwing strings/objects) === - 'Non-Error exception captured', - 'Non-Error promise rejection captured', - /^Object captured as exception with keys:/, - - // === Cross-origin script errors (no useful info) === - 'Script error.', - 'Script error', - - // === React hydration mismatches caused by extensions modifying DOM === - // Note: we only suppress the generic browser messages, NOT "Hydration failed because..." - // which can indicate real SSR/client mismatches in our own code. - /text content does not match/i, - /There was an error while hydrating/i, - - // === Web crawler / bot errors === - 'instantSearchSDKJSBridgeClearHighlight', - - // === Third-party library race conditions === - // cmdk: useSyncExternalStore subscribe called before store context is available - "Cannot read properties of undefined (reading 'subscribe')", - "undefined is not an object (evaluating 't.subscribe')", - - // === Misc known noise === - 'r.default.setDefaultLevel is not a function', - // Clipboard permission denied - 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.', - // Facebook pixel - 'fb_xd_fragment', - ], -}) +) // This export will instrument router navigations, and is only relevant if you enable tracing. export const onRouterTransitionStart = Sentry.captureRouterTransitionStart diff --git a/apps/studio/instrumentation-client.test.ts b/apps/studio/lib/sentry-client-options.test.ts similarity index 77% rename from apps/studio/instrumentation-client.test.ts rename to apps/studio/lib/sentry-client-options.test.ts index c8f5d1aab13cc..74732c21557c3 100644 --- a/apps/studio/instrumentation-client.test.ts +++ b/apps/studio/lib/sentry-client-options.test.ts @@ -1,12 +1,13 @@ -import type { Event as SentryEvent, StackFrame } from '@sentry/nextjs' +import type { Event as SentryEvent, StackFrame } from '@sentry/react' import { describe, expect, it } from 'vitest' import { + buildSentryClientOptions, isBrowserWalletExtensionError, isCancellationRejection, isChallengeExpiredError, isUserAbortedOperation, -} from './instrumentation-client' +} from './sentry-client-options' describe('Sentry beforeSend filtering functions', () => { describe('isBrowserWalletExtensionError', () => { @@ -417,3 +418,80 @@ describe('Sentry beforeSend filtering functions', () => { }) }) }) + +describe('buildSentryClientOptions', () => { + // Representative subset of Sentry's default integrations. `integrations` + // is the function form: Sentry.init calls it with the defaults and installs + // whatever it returns, so dropping these here would disable session + // envelopes (BrowserSession) and window.onerror capture (GlobalHandlers). + const fakeDefaultIntegrations = [{ name: 'BrowserSession' }, { name: 'GlobalHandlers' }] + + const getIntegrationNames = (options: ReturnType) => { + const integrations = options.integrations + if (typeof integrations !== 'function') { + throw new Error('expected the function form of integrations') + } + return integrations(fakeDefaultIntegrations).map((integration) => integration.name) + } + + it('preserves the default integrations passed in by Sentry.init', () => { + for (const includeThirdPartyErrorFilter of [true, false]) { + const names = getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter })) + // browserSessionIntegration is what sends the session envelope on every + // page load; globalHandlers is window.onerror / unhandledrejection. + expect(names).toContain('BrowserSession') + expect(names).toContain('GlobalHandlers') + } + }) + + it('sets the release only when one is provided', () => { + const withRelease = buildSentryClientOptions({ + includeThirdPartyErrorFilter: false, + release: 'abc123', + }) + expect(withRelease.release).toBe('abc123') + + // The key must be ABSENT when no release is passed: on the Next build a + // `release: undefined` entry would override the release injected into + // @sentry/nextjs's init by withSentryConfig (options are spread last). + const withoutRelease = buildSentryClientOptions({ includeThirdPartyErrorFilter: true }) + expect('release' in withoutRelease).toBe(false) + }) + + it('includes the third-party error filter only when the build annotates frames', () => { + // Next build: withSentryConfig injects the applicationKey metadata. + expect( + getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter: true })) + ).toContain('ThirdPartyErrorsFilter') + + // TanStack/Vite build: no bundler metadata — including the integration + // would tag every event third_party_code=true and beforeSend would drop + // them all. + expect( + getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter: false })) + ).not.toContain('ThirdPartyErrorsFilter') + }) + + it('appends build-specific extra integrations', () => { + const options = buildSentryClientOptions({ + includeThirdPartyErrorFilter: false, + extraIntegrations: [{ name: 'FakeRouterTracing' }], + }) + + expect(getIntegrationNames(options)).toContain('FakeRouterTracing') + }) + + it('builds the same shared options for both builds (parity)', () => { + const nextOptions = buildSentryClientOptions({ includeThirdPartyErrorFilter: true }) + const tanstackOptions = buildSentryClientOptions({ includeThirdPartyErrorFilter: false }) + + // Everything except the integrations array must be identical between the + // two runtimes. + const { integrations: _next, ...nextRest } = nextOptions + const { integrations: _tanstack, ...tanstackRest } = tanstackOptions + expect(Object.keys(nextRest)).toEqual(Object.keys(tanstackRest)) + expect(nextRest.tracesSampleRate).toBe(tanstackRest.tracesSampleRate) + expect(nextRest.allowUrls).toEqual(tanstackRest.allowUrls) + expect(nextRest.ignoreErrors).toEqual(tanstackRest.ignoreErrors) + }) +}) diff --git a/apps/studio/lib/sentry-client-options.ts b/apps/studio/lib/sentry-client-options.ts new file mode 100644 index 0000000000000..693f01021dfae --- /dev/null +++ b/apps/studio/lib/sentry-client-options.ts @@ -0,0 +1,368 @@ +// Shared Sentry client-side configuration for BOTH Studio builds: +// +// - Next (pages router): `instrumentation-client.ts` — a Next convention +// file, auto-loaded by Next only — calls `Sentry.init` with these options. +// - TanStack Start (Vite): `sentry.tanstack.ts` calls `Sentry.init` +// with these options from `getRouter()` (router.tsx). TanStack Start does +// not load Next's convention files, so without its own init every +// `Sentry.captureException` there would be a silent no-op. +// +// Keep every shared option in this builder so the two runtimes cannot drift. +// +// `@sentry/react` is what `@sentry/nextjs` wraps on the client (same 10.x +// version, same module instance under pnpm), so building the options against +// it works for both `Sentry.init`s. +import * as Sentry from '@sentry/react' +import { thirdPartyErrorFilterIntegration } from '@sentry/react' +import { hasConsented } from 'common' +import { IS_PLATFORM } from 'common/constants/environment' + +import { MIRRORED_BREADCRUMBS } from '@/lib/breadcrumbs' +import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from '@/lib/sanitize' + +type Integration = Parameters[0] + +const DEFAULT_ERROR_SAMPLE_RATE = 1.0 +const LOW_PRIORITY_ERROR_SAMPLE_RATE = 0.01 +const CHUNK_LOAD_ERROR_PATTERNS = [ + /ChunkLoadError/i, + /Loading chunk [\d]+ failed/i, + /Loading CSS chunk [\d]+ failed/i, +] + +// This is a workaround to ignore hCaptcha related errors. +function isHCaptchaRelatedError(event: Sentry.Event): boolean { + const errors = event.exception?.values ?? [] + for (const error of errors) { + if ( + error.value?.includes('is not a function') && + error.stacktrace?.frames?.some((f) => f.filename === 'api.js') + ) { + return true + } + } + return false +} + +// Filter browser wallet extension errors (e.g., Gate.io wallet) +// These errors come from injected wallet scripts and are not actionable +// Examples: SUPABASE-APP-AFC, SUPABASE-APP-92A +export function isBrowserWalletExtensionError(event: Sentry.Event): boolean { + const frames = event.exception?.values?.flatMap((e) => e.stacktrace?.frames || []) || [] + return frames.some((frame) => { + const filename = frame.filename || frame.abs_path || '' + return filename.includes('gt-window-provider') || filename.includes('wallet-provider') + }) +} + +// Filter user-aborted operations (intentional cancellations) +// These are expected when users cancel requests or navigate away +// Examples: SUPABASE-APP-BG6, SUPABASE-APP-BG7 +export function isUserAbortedOperation(error: unknown, event: Sentry.Event): boolean { + const errorMessage = error instanceof Error ? error.message : '' + const eventMessage = event.message || '' + const message = errorMessage || eventMessage + + return ( + message.includes('operation was aborted') || + message.includes('signal is aborted') || + message.includes('manually canceled') || + message.includes('AbortError') + ) +} + +// Filter cancellation promise rejections (e.g., from query cancellation) +// These occur when operations are intentionally cancelled by the user +// Example: SUPABASE-APP-353 (~466k events) +export function isCancellationRejection(event: Sentry.Event): boolean { + const serialized = event.extra?.__serialized__ as Record | undefined + return serialized?.type === 'cancelation' +} + +// Filter challenge/captcha expired errors (user timeout) +// These happen when users don't complete captcha in time - expected behavior +// Example: SUPABASE-APP-ACC +export function isChallengeExpiredError(error: unknown, event: Sentry.Event): boolean { + const errorMessage = error instanceof Error ? error.message : '' + const eventMessage = event.message || '' + const message = errorMessage || eventMessage + + return message.includes('challenge-expired') +} + +function isChunkLoadError(error: unknown, event: Sentry.Event): boolean { + const errorMessage = error instanceof Error ? error.message : '' + const eventMessage = event.message || '' + const exceptionMessages = event.exception?.values?.map((ex) => ex.value ?? '') ?? [] + const combinedMessages = [errorMessage, eventMessage, ...exceptionMessages].filter(Boolean) + + return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => + combinedMessages.some((message) => pattern.test(message)) + ) +} + +// Tag errors whose stack trace only contains third-party frames (browser extensions, +// injected scripts, etc.). This uses build-time code annotation via the applicationKey +// in next.config.ts to reliably distinguish our code from third-party code. +// We use 'apply-tag' instead of 'drop' so that beforeSend can exempt error boundary +// crashes — these may originate in third-party code but are caused by first-party bugs. +function buildThirdPartyErrorFilterIntegration(): Integration { + return thirdPartyErrorFilterIntegration({ + filterKeys: ['supabase-studio'], + behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', + }) +} + +export interface SentryClientOptionsParams { + /** + * Whether to include `thirdPartyErrorFilterIntegration`. + * + * Only enable this on builds whose bundler annotates stack frames with the + * `supabase-studio` applicationKey (the Next build does, via + * `withSentryConfig` in next.config.ts). On a build WITHOUT the annotation + * no frame carries first-party metadata, so the integration tags EVERY + * event `third_party_code: true` and `beforeSend` would then drop all + * non-error-boundary events. + */ + includeThirdPartyErrorFilter: boolean + /** Build-specific integrations (e.g. TanStack Router browser tracing). */ + extraIntegrations?: Integration[] + /** + * Release identifier for the client. + * + * The SDK SILENTLY DROPS session envelopes when the client has no release + * (`Client.sendSession` early-returns), so a build without a release sends + * no Release Health traffic at all — errors and traces still flow. + * + * The Next build must NOT pass this: `withSentryConfig` injects the release + * (`SENTRY_RELEASE` ?? the Vercel commit SHA) into the bundle at build time, + * and an explicit `release` key — even `undefined` — would override it. + * The TanStack/Vite build runs no Sentry bundler plugin, so it passes the + * commit SHA here instead (see sentry.tanstack.ts). + */ + release?: string +} + +export function buildSentryClientOptions({ + includeThirdPartyErrorFilter, + extraIntegrations = [], + release, +}: SentryClientOptionsParams): Sentry.BrowserOptions { + return { + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + ...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && { + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, + }), + // Conditional spread: see the `release` doc comment above — the key must + // be ABSENT (not `undefined`) so the Next build's injected release wins. + ...(release && { release }), + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + // Enable performance monitoring + tracesSampleRate: 0.02, + + // Function form so Sentry's default integrations (browserSession, + // globalHandlers, breadcrumbs, dedupe, …) are explicitly preserved — this + // is the documented way to extend the defaults, and it can never be + // misread as replacing them. + integrations: (defaultIntegrations) => [ + ...defaultIntegrations, + ...(includeThirdPartyErrorFilter ? [buildThirdPartyErrorFilterIntegration()] : []), + ...extraIntegrations, + ], + + // Only capture errors originating from our own code. + // This is a whitelist on the source URL in stack frames — it drops errors from + // browser extensions, injected scripts, third-party widgets, etc. (FE-2094) + allowUrls: [ + /https?:\/\/(.*\.)?supabase\.(com|co|green|io)/, + /app:\/\//, // Next.js rewrites source URLs to app:// with source maps + ], + beforeBreadcrumb(breadcrumb, _hint) { + const cleanedBreadcrumb = { ...breadcrumb } + + if (cleanedBreadcrumb.category === 'navigation') { + if (typeof cleanedBreadcrumb.data?.from === 'string') { + cleanedBreadcrumb.data.from = sanitizeUrlHashParams(cleanedBreadcrumb.data.from) + } + if (typeof cleanedBreadcrumb.data?.to === 'string') { + cleanedBreadcrumb.data.to = sanitizeUrlHashParams(cleanedBreadcrumb.data.to) + } + } + + MIRRORED_BREADCRUMBS.pushBack(cleanedBreadcrumb) + return cleanedBreadcrumb + }, + beforeSend(event, hint) { + const consent = hasConsented() + + if (!consent) { + return null + } + + if (!IS_PLATFORM) { + return null + } + + const isErrorBoundaryCrash = + event.tags?.globalErrorBoundary === true || event.tags?.globalErrorBoundary === 'true' + const isThirdPartyOnly = + event.tags?.third_party_code === true || event.tags?.third_party_code === 'true' + + // Drop third-party-only errors UNLESS they crashed the page via the global error boundary. + // This preserves noise reduction for browser extensions and injected scripts, + // while ensuring page-crashing errors from third-party libs (caused by first-party bugs) + // are always reported. + if (isThirdPartyOnly && !isErrorBoundaryCrash) { + return null + } + + // Downsample only known high-noise classes; keep all other errors at full rate. + const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes( + `Failed to construct 'URL': Invalid URL` + ) + const isSessionTimeoutEvent = (hint.originalException as any)?.message?.includes( + 'Session error detected' + ) + const isChunkLoadFailure = isChunkLoadError(hint.originalException, event) + + const codeSampleRate = + isInvalidUrlEvent || isSessionTimeoutEvent || isChunkLoadFailure + ? LOW_PRIORITY_ERROR_SAMPLE_RATE + : DEFAULT_ERROR_SAMPLE_RATE + + if (Math.random() > codeSampleRate) { + return null + } + + event.tags = { + ...event.tags, + codeSampleRate: codeSampleRate.toString(), + } + + if (isHCaptchaRelatedError(event)) { + return null + } + + // Drop events where every exception has no stack trace — these are not debuggable. + // Exempt error boundary crashes: even without stack frames, a page crash is always worth reporting. + const exceptions = event.exception?.values ?? [] + if ( + !isErrorBoundaryCrash && + exceptions.length > 0 && + exceptions.every((ex) => !ex.stacktrace?.frames?.length) + ) { + return null + } + + // Filter out errors like 'e._5BLbSXV[t] is not a function' or anything matching '[t] is not a function' + if ( + hint.originalException instanceof Error && + hint.originalException.message.includes('[t] is not a function') + ) { + return null + } + + if (isBrowserWalletExtensionError(event)) { + return null + } + if (isUserAbortedOperation(hint.originalException, event)) { + return null + } + if (isCancellationRejection(event)) { + return null + } + if (isChallengeExpiredError(hint.originalException, event)) { + return null + } + + if (event.breadcrumbs) { + event.breadcrumbs = sanitizeArrayOfObjects(event.breadcrumbs) as Sentry.Breadcrumb[] + } + return event + }, + ignoreErrors: [ + // === Monaco Editor === + 'ResizeObserver', + 's.getModifierState is not a function', + /^Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'/, + + // === Browser extension errors === + // Gate.io wallet + 'shouldSetTallyForCurrentProvider is not a function', + // SAP browser extensions (SAP GUI, SAP Companion) + 'sap is not defined', + // Non-Error objects thrown as exceptions (e.g., Event objects) + '[object Event]', + + // === Third-party SDK errors === + // stripe-js: https://github.com/stripe/stripe-js/issues/26 + 'Failed to load Stripe.js', + // hCaptcha + "undefined is not an object (evaluating 'n.chat.setReady')", + "undefined is not an object (evaluating 'i.chat.setReady')", + + // === Next.js internals === + // Ref: https://github.com/supabase/supabase/pull/9729 + /The provided `href` \(\/org\/\[slug\]\/.*\) value is missing query values/, + // Next.js throws these during navigation, not actual errors + 'NEXT_NOT_FOUND', + 'NEXT_REDIRECT', + + // === User input errors (not bugs) === + // sql-formatter lexer on invalid SQL input + /^Parse error: Unexpected ".+" at line \d+ column \d+$/, + + // === Network / infrastructure (not actionable on FE) === + /504 Gateway Time-out/, + 'Network request failed', + 'Failed to fetch', + 'Load failed', + 'AbortError', + 'TypeError: cancelled', + 'TypeError: Cancelled', + + // === Browser extensions & Google Translate DOM manipulation === + 'Node.insertBefore: Child to insert before is not a child of this node', + 'Node.removeChild: The node to be removed is not a child of this node', + "NotFoundError: Failed to execute 'removeChild' on 'Node'", + "NotFoundError: Failed to execute 'insertBefore' on 'Node'", + 'NotFoundError: The object can not be found here.', + "Cannot read properties of null (reading 'parentNode')", + "Cannot read properties of null (reading 'removeChild')", + "TypeError: can't access dead object", + /^NS_ERROR_/, + + // === Non-Error throws (extensions, third-party libs throwing strings/objects) === + 'Non-Error exception captured', + 'Non-Error promise rejection captured', + /^Object captured as exception with keys:/, + + // === Cross-origin script errors (no useful info) === + 'Script error.', + 'Script error', + + // === React hydration mismatches caused by extensions modifying DOM === + // Note: we only suppress the generic browser messages, NOT "Hydration failed because..." + // which can indicate real SSR/client mismatches in our own code. + /text content does not match/i, + /There was an error while hydrating/i, + + // === Web crawler / bot errors === + 'instantSearchSDKJSBridgeClearHighlight', + + // === Third-party library race conditions === + // cmdk: useSyncExternalStore subscribe called before store context is available + "Cannot read properties of undefined (reading 'subscribe')", + "undefined is not an object (evaluating 't.subscribe')", + + // === Misc known noise === + 'r.default.setDefaultLevel is not a function', + // Clipboard permission denied + 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.', + // Facebook pixel + 'fb_xd_fragment', + ], + } +} diff --git a/apps/studio/router.tsx b/apps/studio/router.tsx index 35a24884b768e..81f101d4c91d8 100644 --- a/apps/studio/router.tsx +++ b/apps/studio/router.tsx @@ -3,6 +3,7 @@ import { createRouter } from '@tanstack/react-router' import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' import { routeTree } from './routeTree.gen' +import { initSentryTanStackClient } from './sentry.tanstack' import { getQueryClient } from '@/data/query-client' import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' import { parseSearch, stringifySearch } from '@/lib/router-search-params' @@ -87,6 +88,12 @@ export function getRouter() { basepath: process.env.NEXT_PUBLIC_BASE_PATH || undefined, }) + // Sentry: nothing loads Next's convention files (instrumentation-client.ts) + // under TanStack Start, so init happens here — the earliest point with + // access to the router instance, which the tracing integration needs. + // No-op on the server and when no DSN is configured (see module). + initSentryTanStackClient(router) + // @tanstack/react-router-ssr-query@1.166.12 pulls in @tanstack/query-core@5.100 // as a peer, but our app pins react-query to 5.83. The QueryClient class is // structurally identical between the two, but TS treats them as nominally diff --git a/apps/studio/sentry.tanstack.test.ts b/apps/studio/sentry.tanstack.test.ts new file mode 100644 index 0000000000000..2adb968ef6aba --- /dev/null +++ b/apps/studio/sentry.tanstack.test.ts @@ -0,0 +1,136 @@ +import type { AnyRouter } from '@tanstack/react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sentryMocks = vi.hoisted(() => ({ + init: vi.fn(), + tanstackRouterBrowserTracingIntegration: vi.fn(() => ({ + name: 'TanStackRouterBrowserTracing', + })), + // Imported at module scope by lib/sentry-client-options.ts, so the mock + // must provide it even though the TanStack init never enables it. + thirdPartyErrorFilterIntegration: vi.fn(() => ({ name: 'ThirdPartyErrorsFilter' })), +})) + +vi.mock('@sentry/react', () => sentryMocks) + +// The integration only needs a router reference to hook navigation events, and +// it is mocked here — a stub stands in for the real router at this boundary. +const fakeRouter = { subscribe: vi.fn() } as unknown as AnyRouter + +// sentry.tanstack.ts keeps a module-level `initialized` flag, so each test +// imports a fresh copy of the module. +async function loadInitializer() { + vi.resetModules() + const { initSentryTanStackClient } = await import('./sentry.tanstack') + return initSentryTanStackClient +} + +describe('initSentryTanStackClient', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('does not initialize Sentry during SSR/prerender (no window)', async () => { + const initSentryTanStackClient = await loadInitializer() + vi.stubGlobal('window', undefined) + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).not.toHaveBeenCalled() + }) + + it('initializes Sentry in the browser with the shared client options', async () => { + vi.stubEnv('NEXT_PUBLIC_SENTRY_DSN', 'https://public@sentry.example.com/1') + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + expect(sentryMocks.init).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://public@sentry.example.com/1', + tracesSampleRate: 0.02, + }) + ) + }) + + it('passes an undefined dsn when NEXT_PUBLIC_SENTRY_DSN is unset (disabled-client no-op)', async () => { + vi.stubEnv('NEXT_PUBLIC_SENTRY_DSN', undefined) + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + // `Sentry.init` without a dsn creates a disabled client, so calling init + // unconditionally is safe for local/self-hosted builds. + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + expect(sentryMocks.init).toHaveBeenCalledWith(expect.objectContaining({ dsn: undefined })) + }) + + it('only initializes once across repeated calls', async () => { + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + }) + + it('still initializes in the browser after an earlier SSR call', async () => { + const initSentryTanStackClient = await loadInitializer() + + // An SSR call must not trip the idempotency guard for the browser call. + vi.stubGlobal('window', undefined) + initSentryTanStackClient(fakeRouter) + expect(sentryMocks.init).not.toHaveBeenCalled() + + vi.unstubAllGlobals() + initSentryTanStackClient(fakeRouter) + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + }) + + it('wires the TanStack Router browser tracing integration for the given router', async () => { + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.tanstackRouterBrowserTracingIntegration).toHaveBeenCalledWith(fakeRouter) + + const [options] = sentryMocks.init.mock.calls[0] + // `integrations` is the function form: Sentry.init calls it with the + // default integrations (browserSession, globalHandlers, …) and installs + // whatever it returns, so the defaults must survive the merge. + expect(options.integrations).toBeTypeOf('function') + const defaultIntegrations = [{ name: 'BrowserSession' }, { name: 'GlobalHandlers' }] + const integrations = options.integrations(defaultIntegrations) + + // Defaults passed in by Sentry.init survive the merge. + expect(integrations).toContainEqual({ name: 'BrowserSession' }) + expect(integrations).toContainEqual({ name: 'GlobalHandlers' }) + expect(integrations).toContainEqual({ name: 'TanStackRouterBrowserTracing' }) + // The Vite build runs no Sentry bundler plugin, so frames carry no + // applicationKey metadata — the third-party filter must stay off or every + // event would be tagged third_party_code=true and dropped by beforeSend. + expect(sentryMocks.thirdPartyErrorFilterIntegration).not.toHaveBeenCalled() + expect(integrations).not.toContainEqual({ name: 'ThirdPartyErrorsFilter' }) + }) + + it('passes the Vercel commit SHA as the release so session envelopes are sent', async () => { + // The SDK silently drops session envelopes when the client has no release + // (`Client.sendSession` early-returns) — without this, Release Health + // sends no /envelope traffic at all on the TanStack build. The Next build + // instead gets its release injected at build time by withSentryConfig. + vi.stubEnv('NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA', 'abc123commit') + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).toHaveBeenCalledWith( + expect.objectContaining({ release: 'abc123commit' }) + ) + }) +}) diff --git a/apps/studio/sentry.tanstack.ts b/apps/studio/sentry.tanstack.ts new file mode 100644 index 0000000000000..075198c390467 --- /dev/null +++ b/apps/studio/sentry.tanstack.ts @@ -0,0 +1,66 @@ +// Sentry client init for the TanStack Start (Vite) build. +// +// NOTE: deliberately not named `sentry.client.tanstack.ts` — TanStack Start's +// import-protection denies `**/*.client.*` modules in the server bundle, and +// this module is imported from router.tsx (shared between client and server). +// It is isomorphic by design: the `typeof window` guard below makes it a +// no-op on the server. +// +// The Next build initializes Sentry via instrumentation-client.ts — a Next +// convention file that nothing loads under TanStack Start. Without this init +// every `Sentry.captureException` in the TanStack runtime (including the +// globalErrorBoundary / routerErrorComponent captures in routes/__root.tsx) +// would be a silent no-op. +// +// Called from `getRouter()` (router.tsx) — the earliest point in the TanStack +// client bootstrap with access to the router instance, which +// `tanstackRouterBrowserTracingIntegration` needs at init time so the +// pageload span is captured, not just later navigations. +// +// Imports `@sentry/react` directly (not `@sentry/nextjs`): this module never +// runs on the Next build, and the real `@sentry/nextjs` doesn't export the +// TanStack Router integration. Under Vite both ids resolve to the same +// `@sentry/react` instance anyway (vite.config.ts aliases `@sentry/nextjs` +// to compat/sentry-nextjs.ts), so app code capturing via `@sentry/nextjs` +// reports through the client initialized here. +import * as Sentry from '@sentry/react' +import type { AnyRouter } from '@tanstack/react-router' + +import { buildSentryClientOptions } from '@/lib/sentry-client-options' + +let isInitialized = false + +export function initSentryTanStackClient(router: AnyRouter) { + // Client-only: getRouter() also runs during SSR/prerender, and the TanStack + // build has no server-side Sentry story yet (the Next build's + // sentry.server.config.ts equivalent would live in a custom server entry). + if (typeof window === 'undefined') return + // getRouter() is called once per pageload today; keep the guard so a future + // second call can't double-init the client. + if (isInitialized) return + isInitialized = true + + // No-ops cleanly when NEXT_PUBLIC_SENTRY_DSN is unset (local/self-hosted): + // `init` without a dsn creates a disabled client, and beforeSend drops + // everything when !IS_PLATFORM regardless. + Sentry.init( + buildSentryClientOptions({ + // The Vite build doesn't run a Sentry bundler plugin, so stack frames + // carry no `supabase-studio` applicationKey metadata. Without the + // metadata the integration would tag EVERY event third_party_code=true + // and beforeSend would drop them all. Leave it off until the Vite build + // annotates frames (@sentry/vite-plugin moduleMetadata). + includeThirdPartyErrorFilter: false, + extraIntegrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)], + // Without a release the SDK silently drops session envelopes + // (`Client.sendSession` early-returns), so Release Health sends nothing + // on this build. The Next build gets its release injected at build time + // by withSentryConfig, which resolves to the Vercel commit SHA; inline + // the same SHA here (vite.config.ts re-exposes VERCEL_GIT_COMMIT_SHA + // under the NEXT_PUBLIC_ name) so both builds report the same release. + // Unset outside Vercel (local/self-hosted), where sessions don't matter — + // so session envelopes only fire on deploys, not on a local dev build. + release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, + }) + ) +} diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 23979bed19b68..60849d6f3d05f 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -372,6 +372,11 @@ export default defineConfig(({ command, mode }) => { // router.tsx). Both are build-time system env vars on Vercel. 'VERCEL_DEPLOYMENT_ID', 'VERCEL_SKEW_PROTECTION_ENABLED', + // Sentry release (sentry.tanstack.ts): the SDK silently drops session + // envelopes when the client has no release, so Release Health would send + // nothing. The commit SHA is also what withSentryConfig resolves the Next + // build's release to, keeping release names aligned across both builds. + 'VERCEL_GIT_COMMIT_SHA', ] as const for (const key of vercelPublicVars) { const value = env[key] @@ -380,6 +385,20 @@ export default defineConfig(({ command, mode }) => { } } + // Sentry init (lib/sentry-client-options.ts, reached via router.tsx) reads + // these at runtime in the browser. When a var is unset it gets no define + // entry above, which would leave a literal `process.env.*` in the built + // bundle — and an undeclared `process` throws in the browser. Inline + // `undefined` as the fallback, mirroring how Next inlines unset + // NEXT_PUBLIC_* vars. + for (const key of [ + 'NEXT_PUBLIC_SENTRY_DSN', + 'NEXT_PUBLIC_SENTRY_ENVIRONMENT', + 'NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA', + ]) { + publicEnvDefines[`process.env.${key}`] ??= 'undefined' + } + // Mirror Next's `basePath` via NEXT_PUBLIC_BASE_PATH. Unlike Next, TanStack // Start has no single knob — the prefix has to be declared in three places // (see BASE_PATH_REDIRECT_GUIDE.md): From a3f2c4ffc1ab8711b6c1ffcb6058d08172eb89c9 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 9 Jul 2026 20:07:17 +0800 Subject: [PATCH 04/12] chore(deps): upgrade to TypeScript 7 (native compiler) (#47757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrades the monorepo to TypeScript 7.0.2, released 2026-07-08. `tsc` is now the native Go compiler ([announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/)) — full turbo typecheck drops from ~56s to ~19s locally. TS 7.0 ships **without a programmatic API** (it lands in 7.1), so this uses Microsoft's recommended side-by-side setup: the `typescript` name resolves to `@typescript/typescript6` (the 6.0 API republished) for API consumers — typescript-eslint and Next.js build typechecking — while `@typescript/native` (the real `typescript@7.0.2`) owns the `tsc` bin that typecheck scripts run. Exactly one version of each is in the lockfile; nothing imports the native package as a library. When 7.1 + tool support lands we can collapse back to a single `typescript` dep in the catalog. **Changed:** - `pnpm-workspace.yaml`: catalog aliases for `typescript` / `@typescript/native` - 17 package.json files: `@typescript/native` added beside each `typescript` dep so every package's `tsc` is the native binary - `apps/studio/tsconfig.json`: exclude `dist/` (gitignored build output) from typechecking **Fixed** (real type errors TS 6 under-reported): - `packages/ui-patterns` CodeBlock: `borderLeft: null` → `undefined` (`CSSProperties` doesn't accept null) - `apps/www` CodeBlock: removed a JSX `@ts-ignore` comment that tsgo doesn't honor and fixed what it masked (untyped `.js` theme objects, possibly-undefined highlighter children) ⚠️ **Merge timing:** the new packages are inside pnpm's 3-day `minimumReleaseAge` window until ~July 11. Installs from the committed lockfile are unaffected (resolution is skipped), but anything that forces a re-resolution before then will fail — hold off merging until the window passes. Note for editors: the compat package has no `lib/tsserver.js`, so VS Code's "Use Workspace Version" won't work — use the bundled TS or the TypeScript Native Preview extension. ## To test - `pnpm install && pnpm typecheck` — all 15 tasks green, and `./node_modules/.bin/tsc --version` prints 7.0.2 - `pnpm lint --filter=studio` — typescript-eslint still parses (resolves the 6.0 API) - `pnpm build --filter=design-system` (or any Next app) — Next's tsconfig validation and build typecheck still work - CodeBlock rendering on www (syntax highlighting, line highlights with/without border) — the two fixes are behavior-neutral but worth an eyeball ## Summary by CodeRabbit * **Improvements / New Features** * Enhanced TypeScript tooling support across the workspace for smoother development builds and checks. * **Bug Fixes** * Code blocks render more reliably when content is empty or missing. * Highlighted code line styling applies more consistently. * **Maintenance** * Studio TypeScript builds now avoid including generated output (such as `dist`) during compilation. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Ivan Vasilov --- apps/design-system/package.json | 1 + apps/docs/package.json | 1 + apps/studio/package.json | 1 + apps/studio/tsconfig.json | 2 +- apps/ui-library/package.json | 1 + apps/www/components/CodeBlock/CodeBlock.tsx | 9 +- apps/www/package.json | 1 + blocks/vue/package.json | 2 + package.json | 1 + packages/ai-commands/package.json | 1 + packages/api-types/package.json | 1 + packages/common/package.json | 1 + packages/config/package.json | 1 + packages/dev-tools/package.json | 1 + packages/eslint-config-supabase/package.json | 1 + packages/icons/package.json | 1 + packages/marketing/package.json | 1 + packages/pg-meta/package.json | 1 + packages/ui-patterns/package.json | 1 + .../ui-patterns/src/CodeBlock/CodeBlock.tsx | 2 +- packages/ui/package.json | 1 + pnpm-lock.yaml | 270 +++++++++++++++++- pnpm-workspace.yaml | 10 +- 23 files changed, 303 insertions(+), 9 deletions(-) diff --git a/apps/design-system/package.json b/apps/design-system/package.json index bad4ec8c6bea8..9636c6b1dd676 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -66,6 +66,7 @@ "tailwindcss": "catalog:", "tsconfig": "workspace:*", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "unist-builder": "3.0.0" } diff --git a/apps/docs/package.json b/apps/docs/package.json index 11a8b9a54ff34..8f2b95802220a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -181,6 +181,7 @@ "tsconfig": "workspace:*", "tsx": "catalog:", "twoslash": "^0.3.1", + "@typescript/native": "catalog:", "typescript": "catalog:", "unist-util-visit-parents": "5.1.3", "vite": "catalog:", diff --git a/apps/studio/package.json b/apps/studio/package.json index 6eaf32965b7be..78122739a90e0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -209,6 +209,7 @@ "require-in-the-middle": "^8.0.0", "tsconfig": "workspace:*", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-tsconfig-paths": "catalog:", diff --git a/apps/studio/tsconfig.json b/apps/studio/tsconfig.json index 5bf688391c280..ab88b97192411 100644 --- a/apps/studio/tsconfig.json +++ b/apps/studio/tsconfig.json @@ -19,5 +19,5 @@ "strictNullChecks": true }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "public/deno/*.ts"] + "exclude": ["node_modules", "public/deno/*.ts", "dist"] } diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index 4d35dc19cdcd3..844cd637bb43c 100644 --- a/apps/ui-library/package.json +++ b/apps/ui-library/package.json @@ -92,6 +92,7 @@ "tailwindcss": "catalog:", "tsconfig": "workspace:*", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:" } diff --git a/apps/www/components/CodeBlock/CodeBlock.tsx b/apps/www/components/CodeBlock/CodeBlock.tsx index 2e8e8f35d437b..f0d9a8da4537c 100644 --- a/apps/www/components/CodeBlock/CodeBlock.tsx +++ b/apps/www/components/CodeBlock/CodeBlock.tsx @@ -2,7 +2,7 @@ import { Check, Copy, File, Terminal } from 'lucide-react' import { useTheme } from 'next-themes' -import { useEffect, useState } from 'react' +import { useEffect, useState, type CSSProperties } from 'react' import CopyToClipboard from 'react-copy-to-clipboard' import { Light as SyntaxHighlighter } from 'react-syntax-highlighter' import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash' @@ -114,17 +114,16 @@ function CodeBlock(props: CodeBlockProps) {
)}
- {/* @ts-ignore */} } className={cn( 'synthax-highlighter border border-default/15 rounded-lg', @@ -152,7 +151,7 @@ function CodeBlock(props: CodeBlockProps) { fontSize: large ? 14 : '0.75rem', }} > - {content} + {content ?? ''} {!props.hideCopy && props.children ? (
diff --git a/apps/www/package.json b/apps/www/package.json index 73c53727667e4..5495f58a98eee 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -90,6 +90,7 @@ "shiki": "^4.2.0", "swiper": "^12.1.2", "typed.js": "^2.0.16", + "@typescript/native": "catalog:", "typescript": "catalog:", "ui": "workspace:*", "ui-patterns": "workspace:*", diff --git a/blocks/vue/package.json b/blocks/vue/package.json index 7ac1bcd10d163..8ff4abe17ccea 100644 --- a/blocks/vue/package.json +++ b/blocks/vue/package.json @@ -26,6 +26,8 @@ "devDependencies": { "shadcn": "^3.3.1", "tsconfig": "workspace:*", + "@typescript/native": "catalog:", + "typescript": "catalog:", "vite": "^7.3.2" } } diff --git a/package.json b/package.json index ff71db442bf3e..4b5c8e45eae81 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "tailwindcss": "catalog:", "tsx": "catalog:", "turbo": "2.9.14", + "@typescript/native": "catalog:", "typescript": "catalog:", "zod": "catalog:" }, diff --git a/packages/ai-commands/package.json b/packages/ai-commands/package.json index bde2074adca7c..68b2181eaccae 100644 --- a/packages/ai-commands/package.json +++ b/packages/ai-commands/package.json @@ -34,6 +34,7 @@ "mdast-util-from-markdown": "^2.0.0", "sql-formatter": "^15.0.0", "tsconfig": "workspace:*", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/packages/api-types/package.json b/packages/api-types/package.json index 6674869602be4..666602c98a743 100644 --- a/packages/api-types/package.json +++ b/packages/api-types/package.json @@ -14,6 +14,7 @@ "devDependencies": { "openapi-typescript": "^7.4.3", "prettier": "*", + "@typescript/native": "catalog:", "typescript": "catalog:" } } diff --git a/packages/common/package.json b/packages/common/package.json index 714cc1059b209..cf143eb26199b 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -34,6 +34,7 @@ "@vitest/ui": "catalog:", "tsconfig": "workspace:*", "type-fest": "5.6.0", + "@typescript/native": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/config/package.json b/packages/config/package.json index 0645eabf1caf3..324105ef29b48 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -15,6 +15,7 @@ "@tailwindcss/postcss": "^4.2.4", "tailwindcss": "catalog:", "tw-animate-css": "^1.4.0", + "@typescript/native": "catalog:", "typescript": "catalog:" } } diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json index 92a11f7a1ddd9..7d32a3ee69473 100644 --- a/packages/dev-tools/package.json +++ b/packages/dev-tools/package.json @@ -31,6 +31,7 @@ "next-router-mock": "^0.9.13", "tailwindcss": "catalog:", "tsconfig": "workspace:*", + "@typescript/native": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/eslint-config-supabase/package.json b/packages/eslint-config-supabase/package.json index c4a70a1cd28d2..878e68ca8e173 100644 --- a/packages/eslint-config-supabase/package.json +++ b/packages/eslint-config-supabase/package.json @@ -16,6 +16,7 @@ "eslint-config-next": "^15.5.0", "eslint-config-prettier": "^10.0.0", "eslint-config-turbo": "^2.5.0", + "@typescript/native": "catalog:", "typescript": "catalog:" } } diff --git a/packages/icons/package.json b/packages/icons/package.json index d222c83c9948e..dc5bc56c5cf8f 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -7,6 +7,7 @@ "clean": "rimraf node_modules .turbo" }, "dependencies": { + "@typescript/native": "catalog:", "typescript": "catalog:", "@supabase/build-icons": "workspace:*" }, diff --git a/packages/marketing/package.json b/packages/marketing/package.json index 4f434138aec1b..f9274b2f2e370 100644 --- a/packages/marketing/package.json +++ b/packages/marketing/package.json @@ -25,6 +25,7 @@ "config": "workspace:*", "tailwindcss": "^4.2.4", "tsconfig": "workspace:", + "@typescript/native": "catalog:", "typescript": "catalog:" }, "license": "MIT" diff --git a/packages/pg-meta/package.json b/packages/pg-meta/package.json index 5adb1dd464cee..94f2cc959478a 100644 --- a/packages/pg-meta/package.json +++ b/packages/pg-meta/package.json @@ -22,6 +22,7 @@ "npm-run-all": "^4.1.5", "pg": "^8.13.1", "postgres-array": "^3.0.2", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index a4c164bd59e97..c9dd3e0956a28 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -797,6 +797,7 @@ "next-router-mock": "^0.9.13", "tailwindcss": "^4.2.4", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "unified": "^11.0.5", "vfile": "^6.0.3", diff --git a/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx b/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx index 8fd0d29552f09..1243173bf6642 100644 --- a/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx +++ b/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx @@ -222,7 +222,7 @@ export const CodeBlock = ({ : 'var(--background-selection)', borderLeft: highlightBorder ? `1px solid ${styleConfig?.highlightBorderColor ? styleConfig?.highlightBorderColor : 'var(--foreground-default)'}` - : null, + : undefined, }, class: 'hljs-line-highlight', } diff --git a/packages/ui/package.json b/packages/ui/package.json index 43f2ac880e048..a724528e4fc11 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,6 +56,7 @@ "config": "workspace:*", "tsconfig": "workspace:*", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cac46b981b07b..7f1e6200ce640 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ catalogs: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3 + '@typescript/native': + specifier: npm:typescript@~7.0.2 + version: 7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1 @@ -79,7 +82,7 @@ catalogs: specifier: ^4.22.0 version: 4.22.4 typescript: - specifier: ~6.0.0 + specifier: ~6.0.2 version: 6.0.2 valtio: specifier: ^1.12.0 @@ -140,6 +143,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.13.14 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 eslint: specifier: ^9.0.0 version: 9.37.0(jiti@2.7.0)(supports-color@8.1.1) @@ -294,6 +300,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 config: specifier: workspace:* version: link:../../packages/config @@ -637,6 +646,9 @@ importers: '@types/unist': specifier: ^2.0.6 version: 2.0.8 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 amaro: specifier: ^1.1.5 version: 1.1.5 @@ -1300,6 +1312,9 @@ importers: '@types/zxcvbn': specifier: ^4.4.1 version: 4.4.2 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vercel/config': specifier: ^0.2.1 version: 0.2.1 @@ -1565,6 +1580,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 config: specifier: workspace:^ version: link:../../packages/config @@ -1661,6 +1679,9 @@ importers: '@supabase/supabase-js': specifier: 'catalog:' version: 2.110.1 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vercel/og': specifier: ^0.6.2 version: 0.6.2 @@ -1954,12 +1975,18 @@ importers: specifier: ^4.5.1 version: 4.5.1(vue@3.5.35(typescript@6.0.2)) devDependencies: + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 shadcn: specifier: ^3.3.1 version: 3.3.1(@types/node@22.13.14)(babel-plugin-macros@3.1.0)(supports-color@8.1.1)(typescript@6.0.2) tsconfig: specifier: workspace:* version: link:../../packages/tsconfig + typescript: + specifier: 'catalog:' + version: 6.0.2 vite: specifier: ^7.3.2 version: 7.3.5(@types/node@22.13.14)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.22.4)(yaml@2.9.0) @@ -2034,6 +2061,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.13.14 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 api-types: specifier: workspace:* version: link:../api-types @@ -2067,6 +2097,9 @@ importers: packages/api-types: devDependencies: + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 openapi-typescript: specifier: ^7.4.3 version: 7.5.2(encoding@0.1.13)(typescript@6.0.2) @@ -2152,6 +2185,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -2183,6 +2219,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.2.4 version: 4.2.4 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 tailwindcss: specifier: 'catalog:' version: 4.2.4 @@ -2229,6 +2268,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 config: specifier: workspace:* version: link:../config @@ -2265,6 +2307,9 @@ importers: '@typescript-eslint/parser': specifier: ^8.48.0 version: 8.48.0(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 eslint-config-next: specifier: ^15.5.0 version: 15.5.4(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) @@ -2304,6 +2349,9 @@ importers: '@supabase/build-icons': specifier: workspace:* version: link:../build-icons + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 react: specifier: 'catalog:' version: 19.2.6 @@ -2339,6 +2387,9 @@ importers: specifier: 'catalog:' version: 3.25.76 devDependencies: + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 config: specifier: workspace:* version: link:../config @@ -2361,6 +2412,9 @@ importers: '@types/pg': specifier: ^8.11.11 version: 8.11.11 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -2503,6 +2557,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -2714,6 +2771,9 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -8595,6 +8655,126 @@ packages: resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@typescript/vfs@1.6.1': resolution: {integrity: sha512-JwoxboBh7Oz1v38tPbkrZ62ZXNHAk9bJ7c9x0eI5zBfBnBYGhURdbnh7Z4smN/MV48Y5OCcZb58n972UtbazsA==} peerDependencies: @@ -16459,6 +16639,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + ua-parser-js@1.0.40: resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==} hasBin: true @@ -24508,6 +24693,66 @@ snapshots: '@typescript-eslint/types': 8.48.0 eslint-visitor-keys: 4.2.1 + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@typescript/vfs@1.6.1(supports-color@8.1.1)(typescript@6.0.2)': dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -34198,6 +34443,29 @@ snapshots: typescript@6.0.2: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + ua-parser-js@1.0.40: {} uc.micro@2.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68c237b24b75f..4112912416c3d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,12 @@ catalog: '@types/node': ^22.0.0 '@types/react': ^19.2.14 '@types/react-dom': ^19.2.3 + # TypeScript 7 has no programmatic API until 7.1, so `typescript` stays aliased + # to the 6.0-API compat package for tools that import it (typescript-eslint, + # Next.js build typechecking), while `@typescript/native` provides the native + # TS 7 `tsc` binary used by typecheck scripts. + # https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/ + '@typescript/native': npm:typescript@~7.0.2 '@vitejs/plugin-react': ^6.0.1 '@vitest/coverage-v8': ^4.1.4 '@vitest/ui': ^4.1.4 @@ -35,7 +41,7 @@ catalog: recharts: ^2.15.4 tailwindcss: ^4.2.4 tsx: ^4.22.0 - typescript: ~6.0.0 + typescript: ~6.0.2 valtio: ^1.12.0 vite: ^8.0.16 vite-tsconfig-paths: ^6.1.1 @@ -60,6 +66,8 @@ minimumReleaseAgeExclude: - '@ai-sdk/*' - '@supabase/*' - '@supabase-labs/*' + - typescript + - '@typescript/*' # First-party, published from supabase-community/mdast-jsx. - mdast-jsx # The following are excluded to fix vulnerablities. From 1987f19d0a511eb8a7f27ab216df3eb20b4e5a1a Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:37:38 -0400 Subject: [PATCH 05/12] feat(sql-editor): add manual save feature preview (#47745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds an opt-in **SQL Editor manual save** feature preview that switches the SQL Editor from autosaving every edit to saving only on demand, and hardens the tab-close flow so unsaved edits are handled correctly. ## Changes **Feature preview** - New `sqlEditorManualSave` flag + `UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE` local-storage toggle, wired into the Feature Preview modal with an explanatory panel. - `useIsSqlEditorManualSaveEnabled` gates behavior on both the flag and the user's preview opt-in. **Editor toolbar** - Save button (with `Cmd+S`) next to Run, plus an autosave status indicator showing dirty/saving/saved state and a shortcut to disable autosave (emits a `sql_editor_autosave_disable_clicked` telemetry event). **Discard on close** - Closing a snippet tab with unsaved edits prompts for confirmation and, on confirm, actually discards the local edits and evicts the cached server copy so the snippet reopens clean. **Decouple tab layout from SQL specifics** - Tabs store gains a generic per-type close-handler registry (`registerTabCloseHandler` / `getCloseConfirmation` / `closeTabs`). The SQL editor registers its discard + confirmation behavior from the save coordinator. - Low-level `removeTab`/`removeTabs` (rename/move re-keying, stale cleanup) intentionally do **not** trigger discard. - Adds `statusOnDiscard` lifecycle transition and `clearSnippetContent` store action. ## Testing - `pnpm --filter=studio typecheck` — clean. - Added unit tests for the close-handler registry (fires on single/multi close, skips re-keying/cleanup removals, respects tab type, selects confirmation copy, unregisters cleanly). ## Summary by CodeRabbit * **New Features** * Added a SQL editor manual-save preview with a “Save” button and `Cmd+S`, plus a modal option to disable manual-save/preview. * Added “unsaved changes” tab status indication when manual-save is enabled. * Introduced tab-type-specific close confirmations (shown only when needed). * **Bug Fixes** * In manual-save mode, closing a SQL tab with unsaved edits now clears local snippet content and refreshes it on reopen. * **Tests** * Added coverage for tab close handlers and confirmation behavior. * **Chores** * Added a persisted setting allowlist entry and tracked autosave-disable clicks via telemetry. --- .../FeaturePreview/FeaturePreviewContext.tsx | 6 + .../FeaturePreview/FeaturePreviewModal.tsx | 2 + .../SqlEditorManualSavePreview.tsx | 17 + .../App/FeaturePreview/useFeaturePreviews.ts | 18 +- .../SQLEditor/SqlTabStatusIndicator.tsx | 33 ++ .../SQLEditor/UtilityPanel/AutosaveStatus.tsx | 70 +++++ .../SQLEditor/UtilityPanel/SaveButton.tsx | 40 +++ .../SQLEditor/UtilityPanel/UtilityActions.tsx | 19 +- .../components/layouts/Tabs/SortableTab.tsx | 53 ++-- apps/studio/components/layouts/Tabs/Tabs.tsx | 295 ++++++++++-------- .../state/sql-editor/sql-editor-lifecycle.ts | 5 + .../sql-editor-save-coordinator.tsx | 75 ++++- .../state/sql-editor/sql-editor-state.ts | 20 +- apps/studio/state/tabs.test.ts | 144 ++++++++- apps/studio/state/tabs.tsx | 117 ++++++- packages/common/constants/local-storage.ts | 2 + packages/common/telemetry-constants.ts | 14 + 17 files changed, 775 insertions(+), 155 deletions(-) create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index ebae4de5b4cff..54680d64291a4 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -127,6 +127,12 @@ export const useIsJitDbAccessEnabled = () => { return jitDbAccessEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS] } +export const useIsSqlEditorManualSaveEnabled = () => { + const { flags } = useFeaturePreviewContext() + const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave') + return sqlEditorManualSaveEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE] +} + export const useIsRLSTesterEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER] diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 20d63b4593a19..179baa81dd552 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -35,6 +35,7 @@ import { JitDbAccessPreview } from './JitDbAccessPreview' import { PgDeltaDiffPreview } from './PgDeltaDiffPreview' import { PlatformWebhooksPreview } from './PlatformWebhooksPreview' import { RLSTesterPreview } from './RLSTesterPreview' +import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview' import { UnifiedLogsPreview } from './UnifiedLogsPreview' import { FeaturePreview, useFeaturePreviews } from './useFeaturePreviews' import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' @@ -52,6 +53,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: , } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx new file mode 100644 index 0000000000000..31d24422693b5 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx @@ -0,0 +1,17 @@ +export const SqlEditorManualSavePreview = () => { + return ( +
+

+ Switch the SQL Editor from autosaving every edit to saving only when you ask it to. +

+
+

Enabling this preview will:

+
    +
  • Stop auto-saving snippet edits as you type
  • +
  • Add a Save button next to Run in the SQL Editor toolbar
  • +
  • Let you save with Cmd+S at any time
  • +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 1cdb5b0c3b030..c256c2ffac20e 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -23,6 +23,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { const platformWebhooksEnabled = useFlag('platformWebhooks') const jitDbAccessEnabled = useFlag('jitDbAccess') const isMarketplaceEnabled = useFlag('marketplaceIntegrations') + const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave') const unifiedLogsDefaultOptIn = useFlag('unifiedLogsDefaultOptIn') @@ -109,7 +110,22 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isDefaultOptIn: false, getRoute: (ref?: string) => `/project/${ref}/integrations`, }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE, + name: 'Disable snippet auto-saving', + discussionsUrl: undefined, + isNew: true, + isPlatformOnly: true, + isDefaultOptIn: false, + enabled: sqlEditorManualSaveEnabled, + }, ].sort((a, b) => Number(b.isNew) - Number(a.isNew)), - [unifiedLogsDefaultOptIn, platformWebhooksEnabled, jitDbAccessEnabled, isMarketplaceEnabled] + [ + unifiedLogsDefaultOptIn, + platformWebhooksEnabled, + jitDbAccessEnabled, + isMarketplaceEnabled, + sqlEditorManualSaveEnabled, + ] ) } diff --git a/apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx b/apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx new file mode 100644 index 0000000000000..3ecea2464f7ba --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx @@ -0,0 +1,33 @@ +import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' +import type { Tab } from '@/state/tabs' + +/** The snippet id a SQL tab represents. Prefer the metadata; fall back to the id scheme. */ +export function getSnippetIdFromTab(tab: Tab): string { + return tab.metadata?.sqlId ?? tab.id.replace(/^sql-/, '') +} + +/** + * VS Code-style unsaved-changes dot for a SQL snippet tab. Renders only in + * manual-save mode when the snippet has unsaved edits — in auto mode edits + * persist on their own, so a dot would just flicker during the debounce. + * + * Registered as the SQL tab type's status indicator (see the save coordinator) + * so the tabs layout can render it without knowing anything about snippets. + */ +export const SqlTabStatusIndicator = ({ tab }: { tab: Tab }) => { + const snapV2 = useSqlEditorV2StateSnapshot() + const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled() + + const status = snapV2.snippets[getSnippetIdFromTab(tab)]?.snippet.status + if (!isManualSaveEnabled || !hasUnsavedChanges(status)) return null + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx new file mode 100644 index 0000000000000..abff6c14b7aa8 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx @@ -0,0 +1,70 @@ +import { LOCAL_STORAGE_KEYS, useFlag } from 'common' +import { PowerOff } from 'lucide-react' +import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import { + useFeaturePreviewModal, + useIsSqlEditorManualSaveEnabled, +} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { IS_PLATFORM } from '@/lib/constants' +import { useTrack } from '@/lib/telemetry/track' +import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' + +export type AutosaveStatusProps = { id: string } + +export const AutosaveStatus = ({ id }: AutosaveStatusProps) => { + const snapV2 = useSqlEditorV2StateSnapshot() + const track = useTrack() + const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled() + const { selectFeaturePreview } = useFeaturePreviewModal() + + // Whether the manual-save preview can actually be opted into. Mirrors the + // feature preview modal's own filter (platform-only + ConfigCat flag), so we + // don't offer to "disable autosave" when there's no preview to switch to. + // `isManualSaveEnabled` also being false for self-hosted / flag-off users is + // why it can't gate this affordance. + const sqlEditorManualSaveFlag = useFlag('sqlEditorManualSave') + const canEnableManualSave = IS_PLATFORM && sqlEditorManualSaveFlag + + if (isManualSaveEnabled) { + const snippet = snapV2.snippets[id] + // A snippet only enters the store on its first edit, so a snippet that + // isn't in the store yet is a fresh, blank, untouched "new query" tab — + // there's nothing to report a save status for. + if (snippet === undefined) return null + + const unsavedChanges = hasUnsavedChanges(snippet.snippet.status) + + return ( + + {unsavedChanges ? 'Unsaved edits' : 'Saved'} + + ) + } + + return ( +
+ Autosave enabled + {canEnableManualSave && ( + + +
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx new file mode 100644 index 0000000000000..87ce9d5e08b00 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx @@ -0,0 +1,40 @@ +import { Loader2 } from 'lucide-react' +import { Button, KeyboardShortcut } from 'ui' + +import { hasUnsavedChanges, isSaving } from '@/state/sql-editor/sql-editor-lifecycle' +import { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' + +interface SqlSaveButtonProps { + id: string + className?: string +} + +export const SqlSaveButton = ({ id, className }: SqlSaveButtonProps) => { + const snapV2 = useSqlEditorV2StateSnapshot() + const { requestSave } = useSqlEditorSaveCoordinator() + + const status = snapV2.snippets[id]?.snippet.status + const saving = isSaving(status) + const isDirty = hasUnsavedChanges(status) && !saving + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 7164ea180ec08..ed3809fbc56c9 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -16,8 +16,11 @@ import { TooltipTrigger, } from 'ui' +import { AutosaveStatus } from './AutosaveStatus' import { SqlRunButton } from './RunButton' +import { SqlSaveButton } from './SaveButton' import SavingIndicator from './SavingIndicator' +import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' import { DatabaseSelector } from '@/components/ui/DatabaseSelector' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' @@ -47,6 +50,7 @@ export const UtilityActions = ({ const { ref } = useParams() const snapV2 = useSqlEditorV2StateSnapshot() const sessionSnap = useSqlEditorSessionSnapshot() + const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled() const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true) const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery( @@ -83,7 +87,10 @@ export const UtilityActions = ({ return (
- {IS_PLATFORM && } + + {/* SavingIndicator reports auto-save progress (spinner/checkmark). In manual + mode AutosaveStatus + the Save button own the status, so hide it there. */} + {IS_PLATFORM && !isManualSaveEnabled && } @@ -204,7 +211,7 @@ export const UtilityActions = ({
-
+
{IS_PLATFORM && ( +
+ +
+ {isManualSaveEnabled && }
diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index 80cc206197a2a..4e9a99d20958c 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -31,6 +31,11 @@ export const SortableTab = ({ }) => { const editor = useEditorType() const tabs = useTabsStateSnapshot() + // Reading the registration version subscribes this tab to handler (un)registers, + // so an indicator registered after first paint (handlers register in an effect) + // is still picked up. The layout stays agnostic of what the indicator shows. + void tabs.handlerRegistrationVersion + const StatusIndicator = tabs.getTabStatusIndicator(tab.type) const { selectedSchema: currentSchema } = useQuerySchemaState() const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id, @@ -101,25 +106,35 @@ export const SortableTab = ({ {tab.label || 'Untitled'}
- { - e.preventDefault() - e.stopPropagation() - }} - className="p-0.5 ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer" - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onPointerDown={(e) => { - e.preventDefault() - e.stopPropagation() - onClose(tab.id) - }} - > - - + {/* VS Code-style slot: the type's status indicator (e.g. an unsaved dot) + shows at rest and swaps to the close button on hover. */} +
+ {StatusIndicator && ( + + + + )} + { + e.preventDefault() + e.stopPropagation() + }} + className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + onClose(tab.id) + }} + > + + +
{index < openTabs.length && ( diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 7ca8624837a88..c1959e98717aa 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -11,6 +11,7 @@ import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { Plus, X } from 'lucide-react' import { useRouter } from 'next/router' +import { useState } from 'react' import { cn, ContextMenu, @@ -27,8 +28,14 @@ import { CollapseButton } from './CollapseButton' import { SortableTab } from './SortableTab' import { TabPreview } from './TabPreview' import { useTabsScroll } from './Tabs.utils' +import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' -import { editorEntityTypes, useTabsStateSnapshot, type Tab } from '@/state/tabs' +import { + editorEntityTypes, + useTabsStateSnapshot, + type Tab, + type TabCloseConfirmation, +} from '@/state/tabs' export const EditorTabs = () => { const { ref, id } = useParams() @@ -37,6 +44,8 @@ export const EditorTabs = () => { const editor = useEditorType() const tabs = useTabsStateSnapshot() + const [pendingClose, setPendingClose] = useState<(() => void) | null>(null) + const [pendingConfirmation, setPendingConfirmation] = useState(null) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -76,8 +85,24 @@ export const EditorTabs = () => { } } + // Runs `performClose` immediately unless one of the tabs' registered close + // handlers asks to confirm first (e.g. a SQL snippet with unsaved edits), in + // which case a confirmation dialog is shown and `performClose` only runs if + // the user confirms. The layout stays agnostic of per-type close semantics. + const closeWithConfirmation = (tabIdsToClose: string[], performClose: () => void) => { + const confirmation = tabs.getCloseConfirmation(tabIdsToClose) + if (confirmation) { + setPendingConfirmation(confirmation) + setPendingClose(() => performClose) + } else { + performClose() + } + } + const handleClose = (tabId: string) => { - tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory }) + closeWithConfirmation([tabId], () => { + tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory }) + }) } const handleCloseAll = () => { @@ -87,9 +112,11 @@ export const EditorTabs = () => { ? tabs.openTabs.filter((x) => !x.startsWith('sql')) : tabs.openTabs.filter((x) => x.startsWith('sql')) - tabs.removeTabs(tabsToClose) - onClearDashboardHistory() - router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`) + closeWithConfirmation(tabsToClose, () => { + tabs.closeTabs(tabsToClose) + onClearDashboardHistory() + router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`) + }) } } @@ -100,13 +127,15 @@ export const EditorTabs = () => { ? tabs.openTabs.filter((x) => !x.startsWith('sql') && x !== tabId) : tabs.openTabs.filter((x) => x.startsWith('sql') && x !== tabId) - tabs.removeTabs(tabsToClose) - onClearDashboardHistory() + closeWithConfirmation(tabsToClose, () => { + tabs.closeTabs(tabsToClose) + onClearDashboardHistory() - const entityId = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] - if (id !== entityId) { - router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${entityId}`) - } + const entityId = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] + if (id !== entityId) { + router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${entityId}`) + } + }) } } @@ -119,13 +148,16 @@ export const EditorTabs = () => { const tabIdx = openedTabs.indexOf(tabId) const activeTabIdx = openedTabs.indexOf(tabs.activeTab!) const tabsToClose = openedTabs.slice(tabIdx + 1) - tabs.removeTabs(tabsToClose) - const isActiveTabClosed = tabIdx < activeTabIdx - if (isActiveTabClosed) { - const id = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] - router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${id}`) - } + closeWithConfirmation(tabsToClose, () => { + tabs.closeTabs(tabsToClose) + + const isActiveTabClosed = tabIdx < activeTabIdx + if (isActiveTabClosed) { + const id = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] + router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${id}`) + } + }) } } @@ -136,116 +168,133 @@ export const EditorTabs = () => { const { tabsListRef } = useTabsScroll({ activeTab: tabs.activeTab, tabCount: editorTabs.length }) return ( - - - - + + - tab.id)} - strategy={horizontalListSortingStrategy} + + - {editorTabs.map((tab, index) => ( - - - handleClose(tab.id)} - /> - - - handleClose(tab.id)}>Close - handleCloseOthers(tab.id)}> - Close Others - - handleCloseRight(tab.id)}> - Close to the Right - - Close All - - - ))} - - - {/* Non-draggable new tab */} - {hasNewTab && ( - tab.id)} + strategy={horizontalListSortingStrategy} > - -
- New -
- { - e.preventDefault() - e.stopPropagation() - }} - className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer" - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onPointerDown={(e) => { - e.preventDefault() - e.stopPropagation() - handleClose('new') - }} - > - - {' '} -
- - )} - - - {!hasNewTab && ( - - router.push( - `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` - ) - } - initial={{ opacity: 0, scale: 0.8, x: -10 }} - animate={{ opacity: 1, scale: 1, x: 0 }} - transition={{ duration: 0.2 }} + {editorTabs.map((tab, index) => ( + + + handleClose(tab.id)} + /> + + + handleClose(tab.id)}>Close + handleCloseOthers(tab.id)}> + Close Others + + handleCloseRight(tab.id)}> + Close to the Right + + Close All + + + ))} + + + {/* Non-draggable new tab */} + {hasNewTab && ( + - - + +
+ New +
+ { + e.preventDefault() + e.stopPropagation() + }} + className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + handleClose('new') + }} + > + + {' '} +
+ )} - -
- - - - - {tabs.activeTab ? : null} - - + + + {!hasNewTab && ( + + router.push( + `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` + ) + } + initial={{ opacity: 0, scale: 0.8, x: -10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + transition={{ duration: 0.2 }} + > + + + )} + +
+ + + + + {tabs.activeTab ? : null} + + + + { + pendingClose?.() + setPendingClose(null) + setPendingConfirmation(null) + }} + onCancel={() => { + setPendingClose(null) + setPendingConfirmation(null) + }} + title={pendingConfirmation?.title ?? 'Unsaved changes'} + description={pendingConfirmation?.description} + /> + ) } diff --git a/apps/studio/state/sql-editor/sql-editor-lifecycle.ts b/apps/studio/state/sql-editor/sql-editor-lifecycle.ts index 190231b1fb73c..8502b51c805e9 100644 --- a/apps/studio/state/sql-editor/sql-editor-lifecycle.ts +++ b/apps/studio/state/sql-editor/sql-editor-lifecycle.ts @@ -60,6 +60,11 @@ export function statusOnEdit(status: SnippetStatus): SnippetStatus { return status === 'saved' ? 'unsaved' : status } +/** Transition when a snippet is discarded — the snippet is now either never persisted or clean. */ +export function statusOnDiscard(status: SnippetStatus): SnippetStatus { + return wasNeverPersisted(status) ? 'new' : 'saved' +} + /** * The lifecycle of a folder in the SQL editor nav, as a single set of * mutually-exclusive states. Like SnippetStatus, this collapses two orthogonal diff --git a/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx b/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx index a582e91753f9d..b591947782779 100644 --- a/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx +++ b/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx @@ -1,15 +1,28 @@ import { useQueryClient } from '@tanstack/react-query' -import { createContext, useContext, useEffect, useMemo, type PropsWithChildren } from 'react' +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + type PropsWithChildren, +} from 'react' import { toast } from 'sonner' import { hasUnsavedChanges } from './sql-editor-lifecycle' import { createSaveMechanism } from './sql-editor-save' -import { createSaveScheduler, type SaveScheduler } from './sql-editor-save-scheduler' +import { createSaveScheduler, type SaveMode, type SaveScheduler } from './sql-editor-save-scheduler' import { sqlEditorState } from './sql-editor-state' +import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { + getSnippetIdFromTab, + SqlTabStatusIndicator, +} from '@/components/interfaces/SQLEditor/SqlTabStatusIndicator' import { upsertContent } from '@/data/content/content-upsert-mutation' import { contentKeys } from '@/data/content/keys' import { createSQLSnippetFolder } from '@/data/content/sql-folder-create-mutation' import { updateSQLSnippetFolder } from '@/data/content/sql-folder-update-mutation' +import { TabsStateContext, type Tab } from '@/state/tabs' type SaveCoordinator = Pick @@ -26,6 +39,12 @@ const SqlEditorSaveCoordinatorContext = createContext(nu export function SqlEditorSaveCoordinatorProvider({ children }: PropsWithChildren) { const queryClient = useQueryClient() + const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled() + const saveModeRef = useRef('auto') + useEffect(() => { + saveModeRef.current = isManualSaveEnabled ? 'manual' : 'auto' + }, [isManualSaveEnabled]) + const scheduler = useMemo(() => { const mechanism = createSaveMechanism({ state: sqlEditorState, @@ -41,12 +60,60 @@ export function SqlEditorSaveCoordinatorProvider({ children }: PropsWithChildren ]) }, }) - // getSaveMode defaults to 'auto'; the manual-save opt-in plugs in here later. - return createSaveScheduler({ state: sqlEditorState, saveMechanism: mechanism, notify: toast }) + // getSaveMode is invoked synchronously from a Valtio `subscribe` callback, + // outside React's render cycle, so it can't read reactive state directly. + // Route it through a ref that's kept in sync via the effect above instead. + return createSaveScheduler({ + state: sqlEditorState, + saveMechanism: mechanism, + notify: toast, + getSaveMode: () => saveModeRef.current, + }) }, [queryClient]) useEffect(() => scheduler.start(), [scheduler]) + // Own what a SQL tab means to the tabs layout — how it closes and the + // unsaved-changes dot it shows — so the layout doesn't have to know about + // snippets. Discarding is a manual-save concept: only manual mode leaves + // unsaved local edits to throw away. In auto mode every edit is already + // persisted (or a debounced save is in flight), so closing must NOT touch the + // snippet's store content or cache — nulling a still-mounted editor's content + // crashes Monaco on dispose, and a snippet left with `content: undefined` + // silently drops the next edit (breaking autosave). Only when there are edits + // to discard do we confirm first, then clear the local content and evict the + // cached server copy so the snippet re-fetches clean when reopened. + const tabsStore = useContext(TabsStateContext) + useEffect(() => { + // A snippet has unsaved edits worth discarding only in manual mode. + const snippetHasUnsavedEdits = (tab: Tab) => + saveModeRef.current === 'manual' && + hasUnsavedChanges(sqlEditorState.snippets[getSnippetIdFromTab(tab)]?.snippet.status) + + return tabsStore.registerTabTypeHandler('sql', { + // VS Code-style unsaved-changes dot, rendered by the tabs layout. + StatusIndicator: SqlTabStatusIndicator, + onClose: (tab) => { + if (!snippetHasUnsavedEdits(tab)) return + const snippetId = getSnippetIdFromTab(tab) + const projectRef = sqlEditorState.snippets[snippetId]?.projectRef + sqlEditorState.clearSnippetContent(snippetId) + queryClient.removeQueries({ queryKey: contentKeys.resource(projectRef, snippetId) }) + }, + confirmClose: (tabs) => { + const dirtyCount = tabs.filter(snippetHasUnsavedEdits).length + if (dirtyCount === 0) return null + return { + title: 'Unsaved changes', + description: + dirtyCount === 1 + ? 'You have unsaved changes in this SQL snippet. Closing it will discard them.' + : `You have unsaved changes in ${dirtyCount} SQL snippets. Closing them will discard those changes.`, + } + }, + }) + }, [tabsStore, queryClient]) + // Warn before the tab is closed/reloaded while any snippet still has unsaved // work (a failed save, a save in flight, or a never-saved snippet). In-app // navigation isn't guarded — the store survives client-side route changes, so diff --git a/apps/studio/state/sql-editor/sql-editor-state.ts b/apps/studio/state/sql-editor/sql-editor-state.ts index 52e79e2252da7..6e1c6cdc5458d 100644 --- a/apps/studio/state/sql-editor/sql-editor-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-state.ts @@ -4,7 +4,12 @@ import { toast } from 'sonner' import { proxy, snapshot, useSnapshot } from 'valtio' import { devtools, proxyMap } from 'valtio/utils' -import { folderStatusOnSaveStart, isNewFolder, statusOnEdit } from './sql-editor-lifecycle' +import { + folderStatusOnSaveStart, + isNewFolder, + statusOnDiscard, + statusOnEdit, +} from './sql-editor-lifecycle' import { sqlEditorSessionState } from './sql-editor-session-state' import type { StateSnippet, StateSnippetFolder } from './types' import type { SnippetWithContent } from '@/data/content/sql-folders-query' @@ -57,6 +62,19 @@ export const sqlEditorState = proxy({ sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet } }, + /** + * + * Clear local snippet content that is not persisted to the database. Deletes + * user edits that have not been saved. + */ + clearSnippetContent: (id: string) => { + const storeSnippet = sqlEditorState.snippets[id] + if (storeSnippet) { + storeSnippet.snippet.content = undefined + storeSnippet.snippet.status = statusOnDiscard(storeSnippet.snippet.status) + } + }, + /** * Update snippet data (e.g name, visibility, chart) and queue for sync saving */ diff --git a/apps/studio/state/tabs.test.ts b/apps/studio/state/tabs.test.ts index 0bd4d81494e2e..9cbfa766a10d3 100644 --- a/apps/studio/state/tabs.test.ts +++ b/apps/studio/state/tabs.test.ts @@ -1,8 +1,19 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import type { NextRouter } from 'next/router' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTabsState } from './tabs' +import { createTabsState, type Tab } from './tabs' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' +const fakeRouter = () => ({ query: { ref: 'default' }, push: vi.fn() }) as unknown as NextRouter + +const sqlTab = (id: string): Tab => ({ + id: `sql-${id}`, + type: 'sql', + label: id, + isPreview: false, + metadata: { sqlId: id }, +}) + describe('tabs recent items', () => { beforeEach(() => { localStorage.clear() @@ -127,3 +138,132 @@ describe('tabs removal', () => { expect(store.openTabs).toEqual(['sql-b']) }) }) + +describe('tabs close handlers', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('runs the registered close handler when a single tab is closed', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + + const onClose = vi.fn() + store.registerTabTypeHandler('sql', { onClose }) + + store.handleTabClose({ id: 'sql-a', router: fakeRouter(), onClearDashboardHistory: () => {} }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onClose.mock.calls[0][0]).toMatchObject({ id: 'sql-a', metadata: { sqlId: 'a' } }) + }) + + it('runs the close handler for every tab closed via closeTabs', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + store.addTab(sqlTab('b')) + + const onClose = vi.fn() + store.registerTabTypeHandler('sql', { onClose }) + + store.closeTabs(['sql-a', 'sql-b']) + + expect(onClose).toHaveBeenCalledTimes(2) + expect(store.openTabs).toHaveLength(0) + }) + + it('does not run close handlers for the low-level removeTab / removeTabs (re-keying, cleanup)', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + store.addTab(sqlTab('b')) + + const onClose = vi.fn() + store.registerTabTypeHandler('sql', { onClose }) + + store.removeTab('sql-a') + store.removeTabs(['sql-b']) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('only runs the handler for the matching tab type', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + store.addTab({ id: 'r-1', type: ENTITY_TYPE.TABLE, label: 'tasks', isPreview: false }) + + const onClose = vi.fn() + store.registerTabTypeHandler('sql', { onClose }) + + store.closeTabs(['sql-a', 'r-1']) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onClose.mock.calls[0][0]).toMatchObject({ id: 'sql-a' }) + }) + + it('returns the confirmation from the handler when any closing tab needs it', () => { + const store = createTabsState('default') + store.addTab(sqlTab('clean')) + store.addTab(sqlTab('dirty')) + + store.registerTabTypeHandler('sql', { + confirmClose: (tabs) => + tabs.some((tab) => tab.metadata?.sqlId === 'dirty') + ? { title: 'Unsaved changes', description: 'Closing will discard them.' } + : null, + }) + + expect(store.getCloseConfirmation(['sql-clean'])).toBeNull() + expect(store.getCloseConfirmation(['sql-clean', 'sql-dirty'])).toEqual({ + title: 'Unsaved changes', + description: 'Closing will discard them.', + }) + }) + + it('passes the full set of closing tabs to the handler so it owns the copy', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + store.addTab(sqlTab('b')) + store.addTab(sqlTab('c')) + + // The handler — not the store — decides the wording, e.g. count-aware copy. + store.registerTabTypeHandler('sql', { + confirmClose: (tabs) => ({ title: 'Unsaved changes', description: `${tabs.length} tabs` }), + }) + + expect(store.getCloseConfirmation(['sql-a', 'sql-b', 'sql-c'])).toEqual({ + title: 'Unsaved changes', + description: '3 tabs', + }) + }) + + it('stops running a handler after it is unregistered', () => { + const store = createTabsState('default') + store.addTab(sqlTab('a')) + + const onClose = vi.fn() + const unregister = store.registerTabTypeHandler('sql', { onClose }) + unregister() + + store.closeTabs(['sql-a']) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('exposes a registered status indicator and bumps the registration version', () => { + const store = createTabsState('default') + const Indicator = () => null + + expect(store.getTabStatusIndicator('sql')).toBeUndefined() + const before = store.handlerRegistrationVersion + + const unregister = store.registerTabTypeHandler('sql', { StatusIndicator: Indicator }) + + expect(store.getTabStatusIndicator('sql')).toBe(Indicator) + expect(store.handlerRegistrationVersion).toBeGreaterThan(before) + + const afterRegister = store.handlerRegistrationVersion + unregister() + + expect(store.getTabStatusIndicator('sql')).toBeUndefined() + expect(store.handlerRegistrationVersion).toBeGreaterThan(afterRegister) + }) +}) diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index 310e6a5c30ac0..2b72256579bb5 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -1,7 +1,14 @@ import { safeLocalStorage, useParams } from 'common' import { partition } from 'lodash' import { type NextRouter } from 'next/router' -import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' +import { + createContext, + PropsWithChildren, + useContext, + useEffect, + useState, + type ComponentType, +} from 'react' import { proxy, subscribe, useSnapshot } from 'valtio' import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils' @@ -43,6 +50,42 @@ export interface Tab { updatedAt?: Date } +/** Copy shown in the confirmation dialog before a close is allowed to proceed. */ +export interface TabCloseConfirmation { + title: string + description: string +} + +/** + * Per-tab-type behavior and UI the tabs layout delegates to, so the layout + * stays agnostic of what any given tab kind means. A domain (e.g. the SQL + * editor) registers a handler for its tab type via `registerTabTypeHandler`; + * tabs of types without a handler close with no extra behavior and show no + * status indicator. + */ +export interface TabTypeHandler { + /** + * Cleanup to run when the user closes a tab of this type (e.g. discarding a + * SQL snippet's unsaved local edits). Runs after the tab has been removed. + */ + onClose?: (tab: Tab) => void + /** + * Whether closing these tabs needs user confirmation. Receives the whole set + * of this type being closed (e.g. a bulk "Close Others") so the handler owns + * the dialog copy, including wording it for one vs. many. Return the copy to + * confirm first; return null/undefined to close immediately. + */ + confirmClose?: (tabs: Tab[]) => TabCloseConfirmation | null | undefined + /** + * Optional component rendered inside the tab to show type-specific status + * (e.g. a VS Code-style unsaved-changes dot for a SQL snippet). Owning the + * component here keeps the layout agnostic of what "status" means per type and + * lets the domain drive its own reactivity. Rendered only when it has + * something to show; otherwise it should render nothing. + */ + StatusIndicator?: ComponentType<{ tab: Tab }> +} + const MAX_RECENT_ITEMS = 8 export interface RecentItem { @@ -128,6 +171,11 @@ export function createTabsState(projectRef: string) { const recentItems = getSavedRecentItems(projectRef) const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef) + // Per-type behavior/UI, kept outside the Valtio proxy so handler closures + // (which may capture non-serializable things like a React Query client or a + // React component) are never proxied or persisted. + const tabHandlers = new Map() + const store = proxy({ // RECENT ITEMS recentItems, @@ -328,6 +376,67 @@ export function createTabsState(projectRef: string) { break } }, + // TAB TYPE HANDLER REGISTRY + // + // Lets a domain own what a tab of its type means — how it closes and what + // status it shows — without the layout having to know. Registered per tab + // type; returns an unregister function. + // + // Bumped on every (un)register so components that render per-type UI (the + // status indicator) re-render to pick up a handler registered after they + // first rendered — handlers register in an effect, which runs after the + // tabs first paint. + handlerRegistrationVersion: 0, + registerTabTypeHandler: (type: TabType, handler: TabTypeHandler) => { + tabHandlers.set(type, handler) + store.handlerRegistrationVersion++ + return () => { + if (tabHandlers.get(type) === handler) { + tabHandlers.delete(type) + store.handlerRegistrationVersion++ + } + } + }, + + // The status-indicator component registered for a tab type, if any. Read + // `handlerRegistrationVersion` alongside this in render to stay reactive to + // late registration. + getTabStatusIndicator: (type: TabType) => tabHandlers.get(type)?.StatusIndicator, + + // The confirmation to show before closing the given tabs, or null if none + // need confirming. Tabs are grouped by type and each type's handler is asked + // about its own set (so it can word the copy for one vs. many); the first + // handler that asks to confirm wins. The store authors no copy itself — that + // stays a concern of the registering domain. + getCloseConfirmation: (ids: string[]): TabCloseConfirmation | null => { + const tabsByType = new Map() + for (const id of ids) { + const tab = store.tabsMap[id] + if (!tab) continue + const group = tabsByType.get(tab.type) + if (group) group.push(tab) + else tabsByType.set(tab.type, [tab]) + } + + for (const [type, tabs] of tabsByType) { + const confirmation = tabHandlers.get(type)?.confirmClose?.(tabs) + if (confirmation) return confirmation + } + return null + }, + + // Close multiple tabs as an intentional user action, running each tab type's + // close handler afterwards. Distinct from `removeTabs`, the low-level store + // mutation used for re-keying (rename/move) and stale cleanup, which must + // NOT trigger discard behavior. + closeTabs: (ids: string[]) => { + const closedTabs = ids + .map((id) => store.tabsMap[id]) + .filter((tab): tab is Tab => tab !== undefined) + store.removeTabs(ids) + closedTabs.forEach((tab) => tabHandlers.get(tab.type)?.onClose?.(tab)) + }, + handleTabClose: ({ id, router, @@ -399,6 +508,12 @@ export function createTabsState(projectRef: string) { } onClose?.(id) + + // Run the tab type's registered close behavior (e.g. discard a SQL + // snippet's unsaved edits). `tabBeingClosed` is captured before removal. + if (tabBeingClosed) { + tabHandlers.get(tabBeingClosed.type)?.onClose?.(tabBeingClosed) + } }, handleTabCloseAll: ({ editor, diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index b31810f31eb0c..9671d925e4ed0 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -26,6 +26,7 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks', UI_PREVIEW_JIT_DB_ACCESS: 'supabase-ui-jit-db-access', UI_PREVIEW_RLS_TESTER: 'supabase-ui-rls-tester', + UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE: 'supabase-ui-sql-editor-manual-save', UI_PREVIEW_MARKETPLACE: 'supabase-ui-marketplace', AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in', @@ -154,6 +155,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS, LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS, + LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE, LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE, LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD, LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST, diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 5ecb01633e3fb..659541bcc9ef2 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -694,6 +694,19 @@ export interface SqlEditorTemplateClickedEvent { groups: TelemetryGroups } +/** + * User clicked the "Disable" button next to the autosave status text in the + * SQL Editor, to open the feature preview modal for manual snippet saving. + * + * @group Events + * @source studio + * @page /project/{ref}/sql/{id} + */ +export interface SqlEditorAutosaveDisableClickedEvent { + action: 'sql_editor_autosave_disable_clicked' + groups: TelemetryGroups +} + /** * User clicked the "Result download CSV" button in the SQL editor. * @@ -3583,6 +3596,7 @@ export type TelemetryEvent = | TableRealtimeDisabledEvent | SqlEditorQuickstartClickedEvent | SqlEditorTemplateClickedEvent + | SqlEditorAutosaveDisableClickedEvent | SqlEditorResultDownloadCsvClickedEvent | SqlEditorResultCopyMarkdownClickedEvent | SqlEditorResultCopyJsonClickedEvent From 1aa23f9f64bb7600fdd4f3305321f961ccfa7fe1 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:14:05 +0200 Subject: [PATCH 06/12] fix: fix several accessibility issues on the organization home page (#47769) ## Problem - Organizations links are not accessible with keyboard - Project list buttons are missing labels - Headings should be sequential ## Summary by CodeRabbit * **Bug Fixes** * Improved keyboard and screen-reader accessibility for project actions and project reference copy controls. * Added clearer tooltip guidance for copying a project reference. * Updated project and organization card interactions for more consistent click and focus behavior. --- .../Home/ProjectList/ProjectCard.tsx | 3 +- .../Home/ProjectList/ProjectTableRow.tsx | 56 +++++++++++-------- .../Organization/OrganizationCard.tsx | 10 +++- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index 1aa338ab6d21d..7923befbe2b42 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -78,7 +78,7 @@ export const ProjectCard = ({
-
{name}
+

{name}

e.preventDefault()}> @@ -91,6 +91,7 @@ export const ProjectCard = ({ e.preventDefault() }} onPointerDown={(e) => e.stopPropagation()} + aria-label={`Project ${name} actions`} /> diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx index 3ab9ae62a6b37..a8e20c56d36b0 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx @@ -12,6 +12,9 @@ import { DropdownMenuTrigger, TableCell, TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { TimestampInfo } from 'ui-patterns/TimestampInfo' @@ -81,29 +84,34 @@ export const ProjectTableRow = ({
-
{name}
- +

{name}

+ + + + + Copy project reference +
{(isGithubIntegrated || isVercelIntegrated || hasPartnerIcon) && (
@@ -175,7 +183,7 @@ export const ProjectTableRow = ({