From b411f52c18399b0c453abebf16cea7a9a6afdbd4 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:33:08 +0100 Subject: [PATCH] fix(base): keep editor scroll stable when inline-embed creation completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The placeholder rendered the full 10-row BaseTableSkeleton (~440px) while waiting for the create response, then BaseTable mounted, ran its own queries, and rendered the same 10-row skeleton again until rows loaded. The actual content for a freshly-created empty base is ~112px — so the swap shrank the doc by ~330px and on a short page the browser clamped scrollY past the new doc bottom, manifesting as a "jump to top of editor." Two changes to keep the height constant end-to-end: 1. BaseTableSkeleton now accepts a `rows` prop (default 10). The placeholder in BaseEmbedView passes `rows={0}` so the skeleton matches the height of the eventual empty base shell — header row + AddRow button, no fake body rows. 2. The Database slash command now seeds `["bases", id]` and the `["base-rows", id, undefined, undefined, undefined]` infinite-query cache from the create response (the endpoint already returns the full base with properties + views; the typed return was just too narrow). BaseTable mounts with baseLoading/rowsLoading already false and skips its own skeleton — no transient grow-then-shrink between placeholder and final content. End state: placeholder height ≈ rendered-empty-base height, and no intermediate skeleton appears while BaseTable is "loading." The scrollY clamp can't fire because the doc never shrinks. --- .../base/components/base-table-skeleton.tsx | 16 ++++++-- .../components/base-embed/base-embed-view.tsx | 11 ++++-- .../components/slash-menu/menu-items.ts | 39 ++++++++++++++++++- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/client/src/features/base/components/base-table-skeleton.tsx b/apps/client/src/features/base/components/base-table-skeleton.tsx index dfcae12fc..287789d5b 100644 --- a/apps/client/src/features/base/components/base-table-skeleton.tsx +++ b/apps/client/src/features/base/components/base-table-skeleton.tsx @@ -5,14 +5,24 @@ import classes from "@/features/base/styles/base-table-skeleton.module.css"; const ROW_NUMBER_WIDTH = 64; const COLUMN_WIDTH = 180; const COLUMN_COUNT = 6; -const ROW_COUNT = 10; +const DEFAULT_ROW_COUNT = 10; // Deterministic per-cell widths so the skeleton doesn't flicker between // renders. Values are rough normal distribution around 55-85 % of cell. const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66]; const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54]; -export function BaseTableSkeleton() { +type BaseTableSkeletonProps = { + // Override the body row count. Pass 0 when rendering as the + // "creating database" placeholder for a freshly-inserted inline embed + // — the eventual empty base has no rows, so a 10-row skeleton would + // shrink ~330px on swap and trip the browser's scrollY clamp. + rows?: number; +}; + +export function BaseTableSkeleton({ + rows = DEFAULT_ROW_COUNT, +}: BaseTableSkeletonProps = {}) { const gridTemplateColumns = [ `${ROW_NUMBER_WIDTH}px`, ...Array.from({ length: COLUMN_COUNT }, () => `${COLUMN_WIDTH}px`), @@ -54,7 +64,7 @@ export function BaseTableSkeleton() { ))} - {Array.from({ length: ROW_COUNT }).map((_, rowIndex) => ( + {Array.from({ length: rows }).map((_, rowIndex) => (
diff --git a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx index 4cd93fd7c..f413bed08 100644 --- a/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx +++ b/apps/client/src/features/editor/components/base-embed/base-embed-view.tsx @@ -74,9 +74,14 @@ export function BaseEmbedView({ node }: NodeViewProps) { let content: React.ReactNode; if (pendingKey) { // Slash command inserted the embed and is awaiting the server's - // assigned pageId — render the same skeleton BaseTable shows on - // its own initial load so the swap is visually a no-op. - content = ; + // assigned pageId. Render with `rows={0}` so the placeholder + // matches the height of the eventual empty base shell — anything + // taller would shrink hundreds of px on swap, and on a short doc + // the browser would clamp scrollY (looks like "page jumps to top + // of editor" when the create response lands). The slash command + // also prefills the React Query cache so BaseTable mounts with + // baseLoading/rowsLoading already false and skips its own skeleton. + content = ; } else if (!pageId) { content = ( diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 0e502cf3a..03c3cafbf 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -55,6 +55,13 @@ import { import api from "@/lib/api-client"; import { notifications } from "@mantine/notifications"; import type { Editor } from "@tiptap/core"; +import type { InfiniteData } from "@tanstack/react-query"; +import { queryClient } from "@/main"; +import type { + IBase, + IBaseRow, +} from "@/features/base/types/base.types"; +import type { IPagination } from "@/lib/types"; // Resolve the position of a baseEmbed placeholder by its pendingKey. // Used by the Database slash command to patch in the real pageId once @@ -530,10 +537,40 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(); try { - const res = await api.post<{ id: string }>("/bases/inline-embed", { + // The create endpoint returns the full base (properties + + // views), not just an id — see base.service.ts `create`. Type + // it as IBase so we can seed the React Query cache below. + const res = await api.post("/bases/inline-embed", { parentPageId, }); + // Seed the caches BaseTable will read on mount so it doesn't + // render its own (10-row) skeleton between our placeholder + // and the actual content. Without this seeding the wrapper + // would grow to ~436px during BaseTable's load and shrink to + // ~112px once the rows resolve — on a short doc the shrink + // pushes scrollY past the new doc bottom and the browser + // clamps to 0, which is the "jump to top" the user reported. + queryClient.setQueryData(["bases", res.data.id], res.data); + queryClient.setQueryData>>( + ["base-rows", res.data.id, undefined, undefined, undefined], + { + pages: [ + { + items: [], + meta: { + limit: 100, + hasNextPage: false, + hasPrevPage: false, + nextCursor: null, + prevCursor: null, + }, + }, + ], + pageParams: [undefined], + }, + ); + const pos = findBaseEmbedPlaceholderPos(editor, pendingKey); if (pos === null) return; editor