Skip to content

Commit 5dbe95e

Browse files
authored
fix(files): reserve image layout space so images stop reflowing on load (#6299)
* fix(files): reserve image layout space so images stop reflowing on load A markdown image with no stored dimensions reserved zero vertical space until it downloaded, then snapped to its natural height and pushed content below it down (cumulative layout shift). Reserve the box up front from the image's intrinsic aspect ratio instead. Store intrinsic width/height as workspace_file metadata (not in the markdown — it stays clean `![](src)`), read it synchronously from the already-loaded file list to reserve a responsive aspect-ratio box on first render, and lazily backfill it once per image on first view via a write-gated, idempotent PATCH. The node view falls back to on-load measurement for the first-ever view and for external images. Images stay fluid (max-width:100%, height:auto). * fix(files): address review — reserve on stale memo, clear dims on content swap - onLoad guards on the memoized storedDimensions the render uses (not a fresh cache read), so a sibling's non-reactive backfill can't leave a view unreserved. - updateWorkspaceFileContent clears width/height when it swaps bytes, so stale dimensions can't be reserved for new content (and the null re-enables backfill). - Keep optimistically-cached dimensions when the PATCH fails (correct measurement; a 403/transient error shouldn't wipe sibling reservations). - Test imports the sibling via the absolute @/ path. * fix(files): re-derive image dimensions on content swap instead of clearing Clearing width/height to NULL on a content swap reopened the width IS NULL backfill path, so a late fire-and-forget PATCH for the previous image could write its stale size onto the new content. Instead, measure the new bytes' intrinsic dimensions server-side (image-size, headers only) and store those (or null for a non-image), so the row always matches the current content and a stale backfill can't apply. * fix(files): self-heal image dimensions from the browser instead of server-measuring Round-3 review: server-side image-size returns raw (non-EXIF) dimensions, and clearing dims on content swap reopened the stale-PATCH race for non-image or unmeasurable content. Move authority to the browser's own naturalWidth/Height (EXIF-correct): the node view reserves from it and reports on any mismatch, and updateWorkspaceFileDimensions overwrites (no width IS NULL gate) so stale values self-correct on the next view. Reverts the server-side measurement and the content-swap dimension touch entirely. * fix(files): clear image dimensions on content swap (completes self-heal) The self-heal rework left the old image's dimensions in the row after a content replacement, so the next view of the new bytes reserved a wrong-sized box before correcting. Clear width/height on the content-swap write so the row never describes stale content: the next view falls back to the baseline first-load reflow and the browser's measurement backfills the correct size. No server-side decode (EXIF-safe), and the client's overwrite-on-mismatch handles a late PATCH. * fix(files): guard dimension writes by content key so a stale PATCH can't persist Ties the dimensions write to the storage key the client measured. The key is regenerated on every content replacement, so an in-flight PATCH measured against superseded bytes is rejected at the DB (WHERE key = measured key) instead of persisting the old aspect ratio for new content. Closes the last stale-ordering window Greptile flagged — the write is now content-version-conditioned, not just corrected on the next render. * chore(files): fix stale route TSDoc and hoist a regex literal (cleanup pass) Post-review /cleanup: the dimensions route TSDoc still described backfill-once behavior (now overwrite-on-mismatch via the content-key CAS); the bare-pixel width regex is hoisted to module scope. No behavior change. * fix(files): reflect the content-version guard outcome in the dimensions response The route returned success:true even when updateWorkspaceFileDimensions matched 0 rows (the CAS rejected a write whose measured key no longer matches the row). Return success:<whether a row was written> and widen the contract response to { success: boolean }. Not an error path — the client's next measurement persists once its file list has the new key; this just stops the API claiming a persist that did not happen. * fix(files): reconcile the cache when a dimension write is content-version-rejected Previously the client discarded a success:false (CAS-rejected) response, leaving its optimistic patch — which is for superseded bytes — lingering in the file-list cache. On rejection, invalidate the list so the cache reconciles with the new content (whose real size persists on its next load). Deliberately NOT a retry: re-sending the old measurement under the new key would write the wrong size. A transport error / read-only 403 still keeps the optimistic value (it's the real displayed size). * docs(files): align stale dimension docs with the overwrite/self-heal behavior Cleanup audit: the ImageDimensionsSource/reportImageDimensions interface docs and one route log string still said backfill-once/no-op; the mechanism overwrites on mismatch to self-correct. Wording only, no behavior change.
1 parent a7d6b96 commit 5dbe95e

14 files changed

Lines changed: 18883 additions & 18 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockUpdateWorkspaceFileDimensions } = vi.hoisted(() => ({
9+
mockUpdateWorkspaceFileDimensions: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
13+
updateWorkspaceFileDimensions: mockUpdateWorkspaceFileDimensions,
14+
}))
15+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
16+
17+
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
18+
const FILE = 'wf_abc123'
19+
const KEY = 'workspace/7727ef3f/screenshot.png'
20+
21+
import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/dimensions/route'
22+
23+
const routeContext = { params: Promise.resolve({ id: WS, fileId: FILE }) }
24+
25+
function buildRequest(body: unknown): NextRequest {
26+
return new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE}/dimensions`, {
27+
method: 'PATCH',
28+
headers: { 'content-type': 'application/json' },
29+
body: JSON.stringify(body),
30+
})
31+
}
32+
33+
describe('PATCH /api/workspaces/[id]/files/[fileId]/dimensions', () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks()
36+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
37+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
38+
mockUpdateWorkspaceFileDimensions.mockResolvedValue(true)
39+
})
40+
41+
it('stores dimensions for a writer, keyed to the content version', async () => {
42+
const res = await PATCH(buildRequest({ key: KEY, width: 1600, height: 900 }), routeContext)
43+
expect(res.status).toBe(200)
44+
expect(await res.json()).toEqual({ success: true })
45+
expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledWith(WS, FILE, {
46+
key: KEY,
47+
width: 1600,
48+
height: 900,
49+
})
50+
})
51+
52+
it('allows an admin', async () => {
53+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
54+
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext)
55+
expect(res.status).toBe(200)
56+
expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledOnce()
57+
})
58+
59+
it('reports success:false when the content-version guard rejects the write (key changed)', async () => {
60+
mockUpdateWorkspaceFileDimensions.mockResolvedValue(false)
61+
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext)
62+
expect(res.status).toBe(200)
63+
expect(await res.json()).toEqual({ success: false })
64+
})
65+
66+
it('rejects an unauthenticated caller before touching the DB', async () => {
67+
authMockFns.mockGetSession.mockResolvedValue(null)
68+
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext)
69+
expect(res.status).toBe(401)
70+
expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled()
71+
})
72+
73+
it('rejects a read-only member (backfill requires write)', async () => {
74+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
75+
const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext)
76+
expect(res.status).toBe(403)
77+
expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled()
78+
})
79+
80+
it('rejects a missing key or non-positive / non-integer dimensions', async () => {
81+
for (const body of [
82+
{ width: 10, height: 10 }, // missing key
83+
{ key: KEY, width: 0, height: 10 },
84+
{ key: KEY, width: 10, height: -5 },
85+
{ key: KEY, width: 10.5, height: 10 },
86+
{ key: KEY, width: 10 },
87+
]) {
88+
const res = await PATCH(buildRequest(body), routeContext)
89+
expect(res.status).toBe(400)
90+
}
91+
expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled()
92+
})
93+
})
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { updateWorkspaceFileDimensionsContract } from '@/lib/api/contracts/workspace-files'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { getSession } from '@/lib/auth'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { updateWorkspaceFileDimensions } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
9+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
10+
11+
const logger = createLogger('WorkspaceFileDimensionsAPI')
12+
13+
/**
14+
* PATCH /api/workspaces/[id]/files/[fileId]/dimensions
15+
*
16+
* Store an image file's intrinsic pixel dimensions — a pure rendering hint the editor uses to reserve
17+
* layout space before the image loads. Requires write permission. The write commits whenever the row
18+
* still holds the measured storage key, overwriting any stale value so a wrong size self-corrects; the
19+
* client reports only on a real mismatch, so this is not storm-y despite not being a backfill-once no-op.
20+
*/
21+
export const PATCH = withRouteHandler(
22+
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
23+
const session = await getSession()
24+
if (!session?.user?.id) {
25+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
26+
}
27+
28+
const parsed = await parseRequest(updateWorkspaceFileDimensionsContract, request, context)
29+
if (!parsed.success) return parsed.response
30+
const { id: workspaceId, fileId } = parsed.data.params
31+
const { key, width, height } = parsed.data.body
32+
33+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
34+
if (permission !== 'admin' && permission !== 'write') {
35+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
36+
}
37+
38+
try {
39+
// `written` is false when the content-version guard rejected the write (the row's storage key no
40+
// longer matches the key the client measured — the content was replaced since). That is not an
41+
// error; the client's next measurement, once its file list has the new key, persists correctly.
42+
const written = await updateWorkspaceFileDimensions(workspaceId, fileId, {
43+
key,
44+
width,
45+
height,
46+
})
47+
return NextResponse.json({ success: written })
48+
} catch (error) {
49+
logger.error('Failed to store workspace file dimensions', {
50+
workspaceId,
51+
fileId,
52+
error: getErrorMessage(error),
53+
})
54+
return NextResponse.json({ error: 'Failed to update dimensions' }, { status: 500 })
55+
}
56+
}
57+
)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { Music } from '@sim/emcn/icons'
55
import dynamic from 'next/dynamic'
66
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
77
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
8-
import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
8+
import {
9+
useWorkspaceFileBinary,
10+
useWorkspaceFileContent,
11+
useWorkspaceImageDimensionsAdapter,
12+
} from '@/hooks/queries/workspace-files'
913
import {
1014
createWorkspaceFileContentSource,
1115
type FileContentSource,
@@ -126,9 +130,10 @@ interface FileViewerProps {
126130

127131
export function FileViewer(props: FileViewerProps) {
128132
const { contentSource, workspaceId } = props
133+
const imageDimensions = useWorkspaceImageDimensionsAdapter(workspaceId)
129134
const source = useMemo(
130-
() => contentSource ?? createWorkspaceFileContentSource(workspaceId),
131-
[contentSource, workspaceId]
135+
() => contentSource ?? createWorkspaceFileContentSource(workspaceId, imageDimensions),
136+
[contentSource, workspaceId, imageDimensions]
132137
)
133138
return (
134139
<FileContentSourceProvider value={source}>

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
import { useEffect, useRef, useState } from 'react'
1+
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
22
import { cn } from '@sim/emcn'
33
import { NodeSelection, Plugin } from '@tiptap/pm/state'
44
import type { ReactNodeViewProps } from '@tiptap/react'
55
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
6-
import { useFileContentSource } from '@/hooks/use-file-content-source'
6+
import { type ImageDimensions, useFileContentSource } from '@/hooks/use-file-content-source'
77
import { MarkdownImage } from './image-schema'
88
import { normalizeLinkHref } from './markdown-fidelity'
99
import { useEditorEditable } from './use-editor-editable'
1010

1111
const MIN_WIDTH = 64
1212

13+
/** A bare pixel count (`"640"`) that needs a `px` suffix, vs. an already-unit'd width (`"50%"`). */
14+
const BARE_PIXEL_WIDTH = /^\d+$/
15+
1316
/**
1417
* Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging
1518
* commits the new pixel width to the `width` attribute, which serializes to `<img width>`.
@@ -24,6 +27,11 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
2427
const [dragWidth, setDragWidth] = useState<number | null>(null)
2528
/** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
2629
const [failed, setFailed] = useState(false)
30+
/**
31+
* Intrinsic dimensions measured from the loaded image — holds the aspect-ratio box for THIS view when
32+
* the content source has no stored dimensions yet (the first-ever view of an image). Reset on src change.
33+
*/
34+
const [measuredDimensions, setMeasuredDimensions] = useState<ImageDimensions | null>(null)
2735
const attrs = node.attrs as {
2836
src?: string
2937
alt?: string
@@ -33,7 +41,16 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
3341
}
3442

3543
useEffect(() => () => dragAbortRef.current?.abort(), [])
36-
useEffect(() => setFailed(false), [attrs.src])
44+
45+
// Reset the load-failure flag and this-session measurement when the src changes — adjusted during
46+
// render (not in an effect) so the previous image's aspect-ratio box never paints for a frame. A `key`
47+
// remount isn't available here: TipTap owns this node view's instantiation.
48+
const [prevSrc, setPrevSrc] = useState(attrs.src)
49+
if (prevSrc !== attrs.src) {
50+
setPrevSrc(attrs.src)
51+
setFailed(false)
52+
setMeasuredDimensions(null)
53+
}
3754

3855
const startResize = (event: React.PointerEvent) => {
3956
event.preventDefault()
@@ -69,16 +86,34 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
6986
}
7087

7188
const committedWidth = attrs.width
72-
? /^\d+$/.test(attrs.width)
89+
? BARE_PIXEL_WIDTH.test(attrs.width)
7390
? `${attrs.width}px`
7491
: attrs.width
7592
: undefined
76-
const widthStyle =
93+
// Stored intrinsic dimensions reserve the box on the very first render. Memoized on the src (not the
94+
// live drag width) so a resize drag never re-scans the file list. Falls back to what we measured on
95+
// load this session for a first-ever view the metadata hasn't caught up on.
96+
const storedDimensions = useMemo(
97+
() => source.getImageDimensions?.(attrs.src) ?? null,
98+
[source, attrs.src]
99+
)
100+
// The browser's post-load measurement is authoritative — EXIF-corrected, and correct even when the
101+
// stored value is stale (e.g. left over after the file's content was replaced) — so it wins once
102+
// available; stored metadata only reserves the box pre-load. Equal in the common case, so no shift.
103+
const intrinsicDimensions = measuredDimensions ?? storedDimensions
104+
const displayWidth =
77105
dragWidth !== null
78-
? { width: `${dragWidth}px` }
79-
: committedWidth
80-
? { width: committedWidth }
81-
: undefined
106+
? `${dragWidth}px`
107+
: (committedWidth ?? (intrinsicDimensions ? `${intrinsicDimensions.width}px` : undefined))
108+
// width + aspect-ratio (with `max-w-full`/`h-auto` from the class list) reserves a responsive box the
109+
// image can't reflow into, per the CLS-avoidance pattern for known-ratio responsive images. React drops
110+
// the undefined keys, so an unmeasured image simply gets no reservation (its prior behavior).
111+
const imageStyle: CSSProperties = {
112+
width: displayWidth,
113+
aspectRatio: intrinsicDimensions
114+
? `${intrinsicDimensions.width} / ${intrinsicDimensions.height}`
115+
: undefined,
116+
}
82117

83118
// Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
84119
// untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
@@ -99,11 +134,28 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
99134
// the resize button sits outside this element, so it keeps its own pointer behavior.)
100135
draggable={editable}
101136
data-drag-handle={editable ? '' : undefined}
102-
style={widthStyle}
137+
style={imageStyle}
103138
onError={() => setFailed(true)}
104-
onLoad={() => setFailed(false)}
139+
onLoad={(event) => {
140+
setFailed(false)
141+
const { naturalWidth, naturalHeight } = event.currentTarget
142+
if (naturalWidth <= 0 || naturalHeight <= 0) return
143+
// The browser's measurement is authoritative. Reserve from it and persist whenever the stored
144+
// metadata is absent or disagrees (EXIF-rotated, or stale after a content swap), so a wrong value
145+
// self-corrects instead of sticking. Compare the memoized `storedDimensions` the render uses, NOT
146+
// a fresh cache read — the memo is non-reactive, and this keeps the guard consistent with render.
147+
if (
148+
storedDimensions &&
149+
storedDimensions.width === naturalWidth &&
150+
storedDimensions.height === naturalHeight
151+
) {
152+
return
153+
}
154+
setMeasuredDimensions({ width: naturalWidth, height: naturalHeight })
155+
source.reportImageDimensions?.(attrs.src, { width: naturalWidth, height: naturalHeight })
156+
}}
105157
className={cn(
106-
'block max-w-full rounded-lg border border-[var(--border)]',
158+
'block h-auto max-w-full rounded-lg border border-[var(--border)]',
107159
editable && 'cursor-grab',
108160
failed &&
109161
'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
6+
import { findWorkspaceFileBySrc } from '@/hooks/queries/utils/find-workspace-file-by-src'
7+
8+
function record(over: Partial<WorkspaceFileRecord>): WorkspaceFileRecord {
9+
return { id: 'wf_x', key: 'workspace/ws1/x.png', ...over } as WorkspaceFileRecord
10+
}
11+
12+
const records = [
13+
record({ id: 'wf_a', key: 'workspace/ws1/a.png' }),
14+
record({ id: 'wf_b', key: 'workspace/ws1/b.png' }),
15+
]
16+
17+
const serveUrl = (key: string) => `/api/files/serve/${encodeURIComponent(key)}?context=workspace`
18+
19+
describe('findWorkspaceFileBySrc', () => {
20+
it('matches a serve URL by storage key', () => {
21+
expect(findWorkspaceFileBySrc(records, serveUrl('workspace/ws1/b.png'))?.id).toBe('wf_b')
22+
})
23+
24+
it('matches a /api/files/view/<id> URL by file id', () => {
25+
expect(findWorkspaceFileBySrc(records, '/api/files/view/wf_a')?.id).toBe('wf_a')
26+
})
27+
28+
it('matches a /workspace/<ws>/files/<id> URL by file id', () => {
29+
expect(findWorkspaceFileBySrc(records, '/workspace/ws1/files/wf_b')?.id).toBe('wf_b')
30+
})
31+
32+
it('returns undefined for a serve URL whose key is not in the list', () => {
33+
expect(findWorkspaceFileBySrc(records, serveUrl('workspace/ws1/missing.png'))).toBeUndefined()
34+
})
35+
36+
it('returns undefined for external, data:, and undefined srcs', () => {
37+
expect(findWorkspaceFileBySrc(records, 'https://example.com/x.png')).toBeUndefined()
38+
expect(findWorkspaceFileBySrc(records, 'data:image/png;base64,AAAA')).toBeUndefined()
39+
expect(findWorkspaceFileBySrc(records, undefined)).toBeUndefined()
40+
})
41+
42+
it('returns undefined when the file list has not loaded yet', () => {
43+
expect(findWorkspaceFileBySrc(undefined, '/api/files/view/wf_a')).toBeUndefined()
44+
})
45+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
2+
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
3+
4+
/**
5+
* Resolve the workspace file record an embedded image `src` points at, matching the persisted serve-URL
6+
* shape by storage key or file id. Returns `undefined` for external / `data:` / unrecognized srcs, and
7+
* when the file list isn't loaded — callers then fall back to on-load measurement rather than reserving
8+
* from metadata.
9+
*/
10+
export function findWorkspaceFileBySrc(
11+
records: WorkspaceFileRecord[] | undefined,
12+
src: string | undefined
13+
): WorkspaceFileRecord | undefined {
14+
const ref = src ? extractEmbeddedFileRef(src) : null
15+
if (!ref || !records) return undefined
16+
return 'key' in ref
17+
? records.find((record) => record.key === ref.key)
18+
: records.find((record) => record.id === ref.fileId)
19+
}

0 commit comments

Comments
 (0)