From 7e3a19e7029e421d2da5d52dd500418330a260cd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Jul 2026 11:44:56 -0700 Subject: [PATCH 1/8] improvement(tables): announce locks on open instead of a header chip Drop the lock entry from the table header actions. A locked table now announces itself once on open via an info toast carrying the Lock settings action for admins; the breadcrumb dropdown remains the permanent entry point. --- .../tables/[tableId]/lock-copy.ts | 8 ++-- .../[workspaceId]/tables/[tableId]/table.tsx | 47 +++++++------------ 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts index f87a5bdbcd1..6f559998503 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -1,6 +1,6 @@ /** - * Single source of truth for lock vocabulary shared by the lock settings modal, - * the blocked-action toast, and the table header chip. Kept out of + * Single source of truth for lock vocabulary shared by the lock settings modal + * and the lock toasts (the on-open announcement and blocked actions). Kept out of * `lib/table/mutation-locks.ts` — that module is server-tainted (importing it * from a client component pulls `next/headers` into the browser bundle). */ @@ -76,8 +76,8 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string /** * Why a locked-table notice was raised. `'status'` is the informational case - * (a non-admin clicking the header lock chip); the rest are actions the user - * just tried and couldn't do. + * (the announcement shown once when a locked table is opened); the rest are + * actions the user just tried and couldn't do. */ export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 1c0daa5fed7..25b12d0ccb6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useReducer, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { Chip, ChipConfirmModal, toast } from '@sim/emcn' import { Download, Lock, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -61,12 +61,7 @@ import { import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants' import { COLUMN_TYPE_ICONS } from './components/table-grid/headers' import { useTable, useTableEventStream } from './hooks' -import { - type BlockedTableAction, - describeBlockedAction, - describeLocks, - lockedNouns, -} from './lock-copy' +import { type BlockedTableAction, describeBlockedAction, lockedNouns } from './lock-copy' import { DEFAULT_TABLE_DETAIL_SORT_DIRECTION, tableDetailParsers, @@ -627,7 +622,10 @@ export function Table({ if (!tableData) return if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current) const { title, text } = describeBlockedAction(action, tableData.locks) - blockedToastIdRef.current = toast.warning(title, { + // 'status' is the on-open announcement — nothing was refused, so it reads + // as information rather than a warning. + const notify = action === 'status' ? toast.info : toast.warning + blockedToastIdRef.current = notify(title, { description: text, ...(canOpenLockSettings ? { @@ -641,23 +639,19 @@ export function Table({ [tableData, canOpenLockSettings] ) + // Announce the lock state once per table on open. Marked announced as soon as + // the table resolves, so an admin who just set locks isn't toasted about them. + const announcedLockTableIdRef = useRef(null) + useEffect(() => { + if (!tableData || announcedLockTableIdRef.current === tableData.id) return + announcedLockTableIdRef.current = tableData.id + if (lockedNouns(tableData.locks).length === 0) return + showBlockedToast('status') + }, [tableData, showBlockedToast]) + const headerActions = useMemo(() => { if (!tableData) return undefined - // Header space is for state, not for settings: the chip appears only once - // something is actually locked, and names the mode so it reads at a glance. - // Reaching the panel on an unlocked table is the dropdown's job. - const anyLocked = lockedNouns(tableData.locks).length > 0 return [ - ...(anyLocked - ? [ - { - label: describeLocks(tableData.locks).name, - icon: Lock, - onClick: () => - userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'), - }, - ] - : []), { label: 'Import CSV', icon: Upload, @@ -673,14 +667,7 @@ export function Table({ disabled: tableData.rowCount === 0, }, ] - }, [ - tableData, - userPermissions.canEdit, - userPermissions.canAdmin, - handleExportCsv, - onRequestImportCsv, - showBlockedToast, - ]) + }, [tableData, userPermissions.canEdit, handleExportCsv, onRequestImportCsv]) // Adding a column is a schema change. The trigger stays visible when the // table is schema-locked and explains itself instead of disappearing. From 7ee27fec662855596eea7687146f8a94074696ac Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Jul 2026 13:50:06 -0700 Subject: [PATCH 2/8] fix(tables): wait for permissions before announcing table locks The lock announcement fires once per table, so a canAdmin that is still false because permissions have not resolved permanently drops the toast's Lock settings action. Arm the one-shot on the permissions decision rather than on table resolution. --- .../workspace/[workspaceId]/tables/[tableId]/table.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 25b12d0ccb6..04b4ea34e46 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -639,15 +639,17 @@ export function Table({ [tableData, canOpenLockSettings] ) - // Announce the lock state once per table on open. Marked announced as soon as - // the table resolves, so an admin who just set locks isn't toasted about them. + // Announce the lock state once per table on open. Unlike the re-rendering + // permission gates, this fires once and can't self-correct, so it waits for + // `canAdmin` to settle instead of treating loading as permitted. const announcedLockTableIdRef = useRef(null) useEffect(() => { - if (!tableData || announcedLockTableIdRef.current === tableData.id) return + if (!tableData || userPermissions.isLoading) return + if (announcedLockTableIdRef.current === tableData.id) return announcedLockTableIdRef.current = tableData.id if (lockedNouns(tableData.locks).length === 0) return showBlockedToast('status') - }, [tableData, showBlockedToast]) + }, [tableData, userPermissions.isLoading, showBlockedToast]) const headerActions = useMemo(() => { if (!tableData) return undefined From f3d85889cbf1c7f895328720500b198ed68838f8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Jul 2026 13:58:13 -0700 Subject: [PATCH 3/8] fix(tables): keep the lock announcement from being swept on navigation The toast provider clears route-scoped toasts in a pathname effect that runs after child effects, so an announcement fired on a warm-cache navigation was added and removed in the same commit. Opt these toasts out of the sweep and dismiss them on unmount so they still don't trail the user. --- .../[workspaceId]/tables/[tableId]/table.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 04b4ea34e46..91acb7ea638 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -627,6 +627,11 @@ export function Table({ const notify = action === 'status' ? toast.info : toast.warning blockedToastIdRef.current = notify(title, { description: text, + // The provider's route-change sweep runs after child effects, so an + // announcement fired on a warm-cache navigation would be cleared in the + // same commit it was added. These toasts belong to this view, so they + // opt out of the sweep and are dismissed on unmount instead. + persistAcrossRoutes: true, ...(canOpenLockSettings ? { action: { label: 'Lock settings', onClick: () => setShowLockSettings(true) }, @@ -651,6 +656,15 @@ export function Table({ showBlockedToast('status') }, [tableData, userPermissions.isLoading, showBlockedToast]) + // Counterpart to `persistAcrossRoutes` above: this view's toasts don't trail + // the user once it goes away. + useEffect( + () => () => { + if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current) + }, + [] + ) + const headerActions = useMemo(() => { if (!tableData) return undefined return [ From cad3600c72c8af636bd3e8f52ba8126c0ea7e9ab Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Jul 2026 14:06:03 -0700 Subject: [PATCH 4/8] fix(tables): drop the lock notice when its action stops being valid A toast's action is captured at creation, so a viewer whose admin access is revoked while the announcement is on screen kept a Lock settings button that opened nothing. Dismiss the notice when the action is no longer available. --- .../workspace/[workspaceId]/tables/[tableId]/table.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 91acb7ea638..1cfd872e2fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -665,6 +665,15 @@ export function Table({ [] ) + // A toast's action is captured when it is created, so a viewer who loses + // admin access mid-toast would keep a Lock settings button that opens + // nothing. Drop the notice instead of leaving it dead. + useEffect(() => { + if (canOpenLockSettings || !blockedToastIdRef.current) return + toast.dismiss(blockedToastIdRef.current) + blockedToastIdRef.current = null + }, [canOpenLockSettings]) + const headerActions = useMemo(() => { if (!tableData) return undefined return [ From 260314a6f6db4745e85e8b2c5b7bb497236f2f7a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Jul 2026 19:07:19 -0700 Subject: [PATCH 5/8] fix(tables): only drop the lock notice when access is actually lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dismiss effect treated "has no access" the same as "just lost access", so on a warm-cache mount — where the announcement and the dismiss both run in the mount commit — a non-admin's notice was torn down the moment it was created. Dismiss on the true-to-false transition only. --- .../workspace/[workspaceId]/tables/[tableId]/table.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 1cfd872e2fe..3b435dfaf29 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -667,9 +667,13 @@ export function Table({ // A toast's action is captured when it is created, so a viewer who loses // admin access mid-toast would keep a Lock settings button that opens - // nothing. Drop the notice instead of leaving it dead. + // nothing. Dismiss on that transition only — a viewer who never had access + // has a legitimate action-less notice that must survive. + const couldOpenLockSettingsRef = useRef(canOpenLockSettings) useEffect(() => { - if (canOpenLockSettings || !blockedToastIdRef.current) return + const lostAccess = couldOpenLockSettingsRef.current && !canOpenLockSettings + couldOpenLockSettingsRef.current = canOpenLockSettings + if (!lostAccess || !blockedToastIdRef.current) return toast.dismiss(blockedToastIdRef.current) blockedToastIdRef.current = null }, [canOpenLockSettings]) From 072f6588506250dd7cf5efa7e7d29698a96adae2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Jul 2026 19:13:14 -0700 Subject: [PATCH 6/8] fix(tables): clear the lock notice when switching tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching tables reuses this component rather than remounting it, and the notice is exempt from the provider's route sweep, so a locked table's announcement stayed on screen over the next table — where its action would have opened that table's lock settings instead. Key the cleanup on tableId. --- .../[workspaceId]/tables/[tableId]/table.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 3b435dfaf29..fa40899e974 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -656,13 +656,17 @@ export function Table({ showBlockedToast('status') }, [tableData, userPermissions.isLoading, showBlockedToast]) - // Counterpart to `persistAcrossRoutes` above: this view's toasts don't trail - // the user once it goes away. + // Counterpart to `persistAcrossRoutes` above: a notice must not outlive the + // table it describes. Keyed on `tableId` because switching tables reuses this + // component instead of remounting it, and the action would then target the + // table the user just moved to. useEffect( () => () => { - if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current) + if (!blockedToastIdRef.current) return + toast.dismiss(blockedToastIdRef.current) + blockedToastIdRef.current = null }, - [] + [tableId] ) // A toast's action is captured when it is created, so a viewer who loses From d2d2156de3563951e399e0d884f440c39aa340ee Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Jul 2026 19:58:35 -0700 Subject: [PATCH 7/8] fix(emcn): sweep route-scoped toasts during render, not after child effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider cleared route-scoped toasts from a pathname effect. Effects run child-first, so it also swept toasts the newly rendered route had just raised: any toast added from a child's mount effect — which is what happens whenever that child's data is already cached — was appended and filtered out in the same commit, before it painted. Move the sweep into render, where it runs before children render, so only toasts predating the navigation are cleared. Timers and heights were already reconciled by effects keyed off `toasts`, so this stays a pure state update. Drops the persistAcrossRoutes workaround the table lock notice needed to dodge the bug; its tableId-keyed cleanup stays, covering embedded swaps that change the prop without a route change. --- .../[workspaceId]/tables/[tableId]/table.tsx | 12 ++--- packages/emcn/src/components/toast/toast.tsx | 49 +++++++++---------- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index fa40899e974..c323c72c2c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -627,11 +627,6 @@ export function Table({ const notify = action === 'status' ? toast.info : toast.warning blockedToastIdRef.current = notify(title, { description: text, - // The provider's route-change sweep runs after child effects, so an - // announcement fired on a warm-cache navigation would be cleared in the - // same commit it was added. These toasts belong to this view, so they - // opt out of the sweep and are dismissed on unmount instead. - persistAcrossRoutes: true, ...(canOpenLockSettings ? { action: { label: 'Lock settings', onClick: () => setShowLockSettings(true) }, @@ -656,10 +651,9 @@ export function Table({ showBlockedToast('status') }, [tableData, userPermissions.isLoading, showBlockedToast]) - // Counterpart to `persistAcrossRoutes` above: a notice must not outlive the - // table it describes. Keyed on `tableId` because switching tables reuses this - // component instead of remounting it, and the action would then target the - // table the user just moved to. + // A notice must not outlive the table it describes — its action targets + // whichever table is current. Keyed on `tableId` so an embedded swap that + // changes the prop without a route change is covered too. useEffect( () => () => { if (!blockedToastIdRef.current) return diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index 3dee9af1541..f5778e2e687 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -396,6 +396,29 @@ export function ToastProvider({ children }: { children?: ReactNode }) { const [mounted, setMounted] = useState(false) const timersRef = useRef(new Map>()) + /** + * Clear the previous route's toasts when the route changes. Toasts flagged + * `persistAcrossRoutes` — global, ongoing-state notifications like the + * connection status — survive; page-scoped ones do not. + * + * Done during render rather than in an effect. Effects run child-first, so an + * effect here would also sweep toasts the newly rendered route just raised: a + * toast added from a child's mount effect — which is what happens whenever + * that child's data is already cached — would be appended and filtered out in + * the same commit, before it ever painted. Adjusting state during render runs + * before children render, so only toasts predating the navigation are cleared. + * + * Stays a pure state update: orphaned timers and measured heights are already + * reconciled by the effects below, which key off `toasts`. + */ + const [sweptPathname, setSweptPathname] = useState(pathname) + if (pathname !== sweptPathname) { + setSweptPathname(pathname) + setToasts((prev) => + prev.some((t) => !t.persistAcrossRoutes) ? prev.filter((t) => t.persistAcrossRoutes) : prev + ) + } + useEffect(() => { setMounted(true) }, []) @@ -459,27 +482,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) { setHeights({}) }, []) - /** - * Clear only route-scoped toasts. Toasts flagged `persistAcrossRoutes` — - * global, ongoing-state notifications like the connection status — survive, - * everything else (page-scoped notifications) is cleared on navigation. - */ - const dismissRouteScopedToasts = useCallback(() => { - setToasts((prev) => { - const kept = prev.filter((t) => t.persistAcrossRoutes) - if (kept.length === prev.length) return prev - for (const t of prev) { - if (t.persistAcrossRoutes) continue - const timer = timersRef.current.get(t.id) - if (timer) { - clearTimeout(timer) - timersRef.current.delete(t.id) - } - } - return kept - }) - }, []) - const measureToast = useCallback((id: string, height: number) => { setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height })) }, []) @@ -536,11 +538,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) { } }, []) - /** On navigation, clear route-scoped toasts so they don't trail the user; `persistAcrossRoutes` toasts survive. */ - useEffect(() => { - dismissRouteScopedToasts() - }, [pathname, dismissRouteScopedToasts]) - /** Held in a ref (seeded once from the stable `addToast`) so the module-level `toast` binds to the live provider. */ const toastFn = useRef(createToastFn(addToast)) From b505ef69b11058b19d7bd8d00512243eee313923 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 27 Jul 2026 20:07:04 -0700 Subject: [PATCH 8/8] fix(tables): reset the announce latch when leaving a table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tableId cleanup dismissed the notice on departure but left the latch holding that table's id, so returning before another table announced — a quick there-and-back, or a second table that never loaded — found the latch matching and stayed silent. Leaving ends the visit, so clear the latch with it. --- .../app/workspace/[workspaceId]/tables/[tableId]/table.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index c323c72c2c7..a76f01abab5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -653,9 +653,11 @@ export function Table({ // A notice must not outlive the table it describes — its action targets // whichever table is current. Keyed on `tableId` so an embedded swap that - // changes the prop without a route change is covered too. + // changes the prop without a route change is covered too. Leaving ends the + // visit, so the latch resets and coming back announces again. useEffect( () => () => { + announcedLockTableIdRef.current = null if (!blockedToastIdRef.current) return toast.dismiss(blockedToastIdRef.current) blockedToastIdRef.current = null