Skip to content

Commit ed99f66

Browse files
committed
multiple fixes
1 parent 0074fab commit ed99f66

14 files changed

Lines changed: 865 additions & 122 deletions

File tree

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,24 @@ function getWorkspaceIdForCompile(key: string): string | undefined {
153153
return parseWorkspaceFileKey(key) ?? undefined
154154
}
155155

156+
const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable'
157+
const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
158+
159+
/**
160+
* Cache-Control for a served file. A versioned request (`?v=<updatedAt>`) addresses
161+
* content-immutable bytes — generated docs are content-addressed and the version
162+
* bumps on every edit — so the browser may cache it indefinitely; re-opens and
163+
* focus refetches then resolve from cache with no round trip. Unversioned workspace
164+
* reads stay revalidated because the same storage key is edited in place.
165+
*/
166+
function resolveServeCacheControl(
167+
versioned: boolean,
168+
context: string | undefined
169+
): string | undefined {
170+
if (versioned) return IMMUTABLE_CACHE_CONTROL
171+
return context === 'workspace' ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined
172+
}
173+
156174
export const GET = withRouteHandler(
157175
async (request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => {
158176
try {
@@ -190,8 +208,10 @@ export const GET = withRouteHandler(
190208

191209
const query = fileServeQuerySchema.parse({
192210
raw: request.nextUrl.searchParams.get('raw'),
211+
v: request.nextUrl.searchParams.get('v'),
193212
})
194213
const raw = query.raw === '1'
214+
const versioned = query.v != null
195215

196216
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
197217

@@ -206,10 +226,10 @@ export const GET = withRouteHandler(
206226
const userId = authResult.userId
207227

208228
if (isUsingCloudStorage()) {
209-
return await handleCloudProxy(cloudKey, userId, raw, request.signal)
229+
return await handleCloudProxy(cloudKey, userId, raw, versioned, request.signal)
210230
}
211231

212-
return await handleLocalFile(cloudKey, userId, raw, request.signal)
232+
return await handleLocalFile(cloudKey, userId, raw, versioned, request.signal)
213233
} catch (error) {
214234
// An in-progress/incomplete doc source fails to compile — this is expected
215235
// mid-generation, not a server fault. Return 409 (not 500) so it isn't an
@@ -237,6 +257,7 @@ async function handleLocalFile(
237257
filename: string,
238258
userId: string,
239259
raw: boolean,
260+
versioned: boolean,
240261
signal: AbortSignal | undefined
241262
): Promise<NextResponse> {
242263
const ownerKey = `user:${userId}`
@@ -283,7 +304,7 @@ async function handleLocalFile(
283304
buffer: fileBuffer,
284305
contentType,
285306
filename: displayName,
286-
cacheControl: contextParam === 'workspace' ? 'private, no-cache, must-revalidate' : undefined,
307+
cacheControl: resolveServeCacheControl(versioned, contextParam),
287308
})
288309
} catch (error) {
289310
logger.error('Error reading local file:', error)
@@ -295,6 +316,7 @@ async function handleCloudProxy(
295316
cloudKey: string,
296317
userId: string,
297318
raw = false,
319+
versioned = false,
298320
signal: AbortSignal | undefined = undefined
299321
): Promise<NextResponse> {
300322
const ownerKey = `user:${userId}`
@@ -349,7 +371,7 @@ async function handleCloudProxy(
349371
buffer: fileBuffer,
350372
contentType,
351373
filename: displayName,
352-
cacheControl: context === 'workspace' ? 'private, no-cache, must-revalidate' : undefined,
374+
cacheControl: resolveServeCacheControl(versioned, context),
353375
})
354376
} catch (error) {
355377
logger.error('Error downloading from cloud storage:', error)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { resolveDocPreviewBinary } from './use-doc-preview-binary'
5+
import { resolveDocPreviewBinary, stepDocPreviewBinary } from './use-doc-preview-binary'
66

77
function buffer(byte: number): ArrayBuffer {
88
return new Uint8Array([byte]).buffer
@@ -94,3 +94,91 @@ describe('resolveDocPreviewBinary', () => {
9494
expect(result.error).toBe(err)
9595
})
9696
})
97+
98+
describe('stepDocPreviewBinary', () => {
99+
it('shows loading for a committed file whose first fetch has not resolved', () => {
100+
const step = stepDocPreviewBinary({
101+
fileChanged: false,
102+
data: undefined,
103+
isPlaceholderData: false,
104+
error: null,
105+
hasCommittedContent: true,
106+
prevHasResolvedForFile: false,
107+
prevLastGood: null,
108+
})
109+
110+
expect(step.resolved.state).toBe('loading')
111+
expect(step.hasResolvedForFile).toBe(false)
112+
expect(step.lastGood).toBeNull()
113+
})
114+
115+
it('advances the head and records resolution on a fresh success', () => {
116+
const fresh = buffer(1)
117+
const step = stepDocPreviewBinary({
118+
fileChanged: false,
119+
data: fresh,
120+
isPlaceholderData: false,
121+
error: null,
122+
hasCommittedContent: true,
123+
prevHasResolvedForFile: false,
124+
prevLastGood: null,
125+
})
126+
127+
expect(step.resolved.state).toBe('ready')
128+
expect(step.resolved.data).toBe(fresh)
129+
expect(step.hasResolvedForFile).toBe(true)
130+
expect(step.lastGood).toBe(fresh)
131+
})
132+
133+
it('ignores the prior-file placeholder on a file change (no cross-file bleed)', () => {
134+
const priorFileBytes = buffer(1)
135+
const step = stepDocPreviewBinary({
136+
fileChanged: true,
137+
data: priorFileBytes,
138+
isPlaceholderData: true,
139+
error: null,
140+
hasCommittedContent: true,
141+
prevHasResolvedForFile: true,
142+
prevLastGood: priorFileBytes,
143+
})
144+
145+
expect(step.resolved.state).toBe('loading')
146+
expect(step.resolved.data).toBeNull()
147+
expect(step.hasResolvedForFile).toBe(false)
148+
expect(step.lastGood).toBeNull()
149+
})
150+
151+
it('holds the previous version as stale during a same-file recompile', () => {
152+
const v1 = buffer(1)
153+
const step = stepDocPreviewBinary({
154+
fileChanged: false,
155+
data: v1,
156+
isPlaceholderData: true,
157+
error: null,
158+
hasCommittedContent: true,
159+
prevHasResolvedForFile: true,
160+
prevLastGood: v1,
161+
})
162+
163+
expect(step.resolved.state).toBe('stale')
164+
expect(step.resolved.data).toBe(v1)
165+
expect(step.hasResolvedForFile).toBe(true)
166+
})
167+
168+
it('keeps the last good binary and suppresses the error after a failed refetch', () => {
169+
const v1 = buffer(1)
170+
const step = stepDocPreviewBinary({
171+
fileChanged: false,
172+
data: undefined,
173+
isPlaceholderData: false,
174+
error: new Error('boom'),
175+
hasCommittedContent: true,
176+
prevHasResolvedForFile: true,
177+
prevLastGood: v1,
178+
})
179+
180+
expect(step.resolved.state).toBe('stale')
181+
expect(step.resolved.data).toBe(v1)
182+
expect(step.resolved.error).toBeNull()
183+
})
184+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,52 @@ export function resolveDocPreviewBinary({
6767
}
6868
}
6969

70+
interface DocPreviewStepArgs {
71+
fileChanged: boolean
72+
data: ArrayBuffer | undefined
73+
isPlaceholderData: boolean
74+
error: Error | null
75+
hasCommittedContent: boolean
76+
prevHasResolvedForFile: boolean
77+
prevLastGood: ArrayBuffer | null
78+
}
79+
80+
interface DocPreviewStep {
81+
resolved: ResolvedDocPreview
82+
hasResolvedForFile: boolean
83+
lastGood: ArrayBuffer | null
84+
}
85+
86+
/**
87+
* Pure per-render step for {@link useDocPreviewBinary}: folds the prior last-good
88+
* binary and the "has a fresh binary resolved for this file yet" flag with the
89+
* current query result. On a file change the prior file's last-good and resolved
90+
* flag are dropped, and the keep-previous placeholder (which still holds the prior
91+
* file's bytes) is ignored until a fresh binary resolves for the new file.
92+
*/
93+
export function stepDocPreviewBinary({
94+
fileChanged,
95+
data,
96+
isPlaceholderData,
97+
error,
98+
hasCommittedContent,
99+
prevHasResolvedForFile,
100+
prevLastGood,
101+
}: DocPreviewStepArgs): DocPreviewStep {
102+
const lastGood = fileChanged ? null : prevLastGood
103+
const hasResolvedForFile =
104+
(fileChanged ? false : prevHasResolvedForFile) || (Boolean(data) && !isPlaceholderData)
105+
const placeholderFromPriorFile = isPlaceholderData && !hasResolvedForFile
106+
const resolved = resolveDocPreviewBinary({
107+
data: placeholderFromPriorFile ? undefined : data,
108+
isPlaceholderData,
109+
error,
110+
lastGood,
111+
hasCommittedContent,
112+
})
113+
return { resolved, hasResolvedForFile, lastGood: resolved.lastGood }
114+
}
115+
70116
/**
71117
* Resolves the compiled binary to render for a generated or uploaded document and
72118
* retains the last successfully fetched binary as a fallback.
@@ -89,30 +135,27 @@ export function useDocPreviewBinary(workspaceId: string, file: DocPreviewFile):
89135
const lastGoodRef = useRef<ArrayBuffer | null>(null)
90136
const fileIdRef = useRef(file.id)
91137
const hasResolvedForFileRef = useRef(false)
92-
if (fileIdRef.current !== file.id) {
138+
const fileChanged = fileIdRef.current !== file.id
139+
if (fileChanged) {
93140
fileIdRef.current = file.id
94-
lastGoodRef.current = null
95-
hasResolvedForFileRef.current = false
96-
}
97-
if (query.data && !query.isPlaceholderData) {
98-
hasResolvedForFileRef.current = true
99141
}
100142

101-
const placeholderFromPriorFile = query.isPlaceholderData && !hasResolvedForFileRef.current
102-
103-
const resolved = resolveDocPreviewBinary({
104-
data: placeholderFromPriorFile ? undefined : query.data,
143+
const step = stepDocPreviewBinary({
144+
fileChanged,
145+
data: query.data,
105146
isPlaceholderData: query.isPlaceholderData,
106147
error: (query.error as Error | null) ?? null,
107-
lastGood: lastGoodRef.current,
108148
hasCommittedContent: (file.size ?? 0) > 0,
149+
prevHasResolvedForFile: hasResolvedForFileRef.current,
150+
prevLastGood: lastGoodRef.current,
109151
})
110-
lastGoodRef.current = resolved.lastGood
152+
hasResolvedForFileRef.current = step.hasResolvedForFile
153+
lastGoodRef.current = step.lastGood
111154

112155
return {
113-
data: resolved.data,
114-
state: resolved.state,
115-
error: resolved.error,
156+
data: step.resolved.data,
157+
state: step.resolved.state,
158+
error: step.resolved.error,
116159
dataUpdatedAt: query.dataUpdatedAt,
117160
}
118161
}

0 commit comments

Comments
 (0)