Skip to content

Commit 7ad54dd

Browse files
committed
Render live charts without server hydration
1 parent b2c4598 commit 7ad54dd

18 files changed

Lines changed: 202 additions & 516 deletions

File tree

apps/sim/app/api/files/public/[token]/content/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
1212
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
1313
import { downloadFile } from '@/lib/uploads/core/storage-service'
1414
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
15-
import { isSimPageSource } from '@/lib/workspace-files/page-compile'
15+
import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
1616
import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server'
1717
import {
1818
createErrorResponse,
@@ -92,7 +92,10 @@ export const GET = withRouteHandler(
9292
buffer = servable.buffer
9393
contentType = servable.contentType
9494
} else if (
95-
file.originalName.toLowerCase().endsWith('.html') &&
95+
// Sim pages store an extensionless name — the record type marks them;
96+
// legacy pages still carry .html.
97+
(file.contentType === SIM_PAGE_CONTENT_TYPE ||
98+
file.originalName.toLowerCase().endsWith('.html')) &&
9699
isSimPageSource(raw.toString('utf8'))
97100
) {
98101
// The pdf model for pages: the stored .html is source; a share serves

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { resolveStoredFileContext } from '@/lib/uploads/server/metadata'
2222
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
2323
import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api'
2424
import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key'
25-
import { isSimPageSource } from '@/lib/workspace-files/page-compile'
25+
import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
2626
import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server'
2727
import { verifyFileAccess } from '@/app/api/files/authorization'
2828
import {
@@ -78,27 +78,34 @@ async function resolveServableBytes(params: {
7878
options: ServeOptions
7979
ownerKey: string | undefined
8080
filePrincipal?: Principal
81+
/** The stored record's content type, where the caller has the record. */
82+
fileType?: string
8183
signal: AbortSignal | undefined
8284
}): Promise<{ buffer: Buffer; contentType: string }> {
83-
const { buffer, filename, storageKey, workspaceId, options, ownerKey, filePrincipal, signal } =
84-
params
85+
const {
86+
buffer,
87+
filename,
88+
storageKey,
89+
workspaceId,
90+
options,
91+
ownerKey,
92+
filePrincipal,
93+
fileType,
94+
signal,
95+
} = params
8596
if (options.raw) return { buffer, contentType: getContentType(filename) }
8697

87-
// The pdf model for pages: a `.html` file stores its SOURCE (frontmatter +
98+
// The pdf model for pages: a page file stores its SOURCE (frontmatter +
8899
// markdown + sim: fences) and serving compiles it to the rendered document,
89100
// the same way a .pdf key stores its script and serves the binary. Raw
90101
// requests above still return the source; bespoke/legacy HTML falls through
91-
// untouched.
92-
if (filename.toLowerCase().endsWith('.html')) {
102+
// untouched. Sim pages store an EXTENSIONLESS name — the record type marks
103+
// them; legacy pages still carry .html.
104+
if (fileType === SIM_PAGE_CONTENT_TYPE || filename.toLowerCase().endsWith('.html')) {
93105
const text = buffer.toString('utf8')
94106
if (isSimPageSource(text)) {
95107
return {
96-
buffer: Buffer.from(
97-
// The principal lets referenced table-backed charts read CURRENT
98-
// rows under the viewer's own authorization on every serve.
99-
await renderSimPageDocumentWithAssets(text, { workspaceId, principal: filePrincipal }),
100-
'utf8'
101-
),
108+
buffer: Buffer.from(await renderSimPageDocumentWithAssets(text, { workspaceId }), 'utf8'),
102109
contentType: 'text/html',
103110
}
104111
}
@@ -293,6 +300,7 @@ async function handleWorkspaceFile(
293300
options,
294301
ownerKey,
295302
filePrincipal: principal,
303+
fileType: file.type,
296304
signal: request.signal,
297305
})
298306

apps/sim/app/api/files/utils.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,15 +237,18 @@ export function encodeFilenameForHeader(storageKey: string): string {
237237
}
238238

239239
export function createFileResponse(file: FileResponse): NextResponse {
240-
const { contentType, disposition } = getSecureFileHeaders(file.filename, file.contentType)
241-
242240
// Sim pages store an extensionless name and serve/download as compiled
243241
// HTML — re-append the extension so the saved file opens in a browser.
242+
// Decided from the CALLER's content type (getSecureFileHeaders downgrades
243+
// text/html), and BEFORE the header decision, so the .html name gets the
244+
// same forced-attachment treatment a legacy .html file gets.
244245
const servedFilename =
245-
contentType === 'text/html' && !/\.[A-Za-z0-9]{1,8}$/.test(file.filename)
246+
file.contentType === 'text/html' && !/\.[A-Za-z0-9]{1,8}$/.test(file.filename)
246247
? `${file.filename}.html`
247248
: file.filename
248249

250+
const { contentType, disposition } = getSecureFileHeaders(servedFilename, file.contentType)
251+
249252
const headers: Record<string, string> = {
250253
'Content-Type': contentType,
251254
'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(servedFilename)}`,

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

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,41 @@ import { getErrorMessage } from '@sim/utils/errors'
55
import type { EChartsOption } from 'echarts'
66
import { useTheme } from 'next-themes'
77
import { buildChartRenderOption } from '@/lib/charts/option'
8-
import { type ChartSpec, parseChartSpec, shapeTableRows } from '@/lib/charts/spec'
9-
import { getColumnId } from '@/lib/table/column-keys'
8+
import {
9+
CHART_ROWS_DEFAULT,
10+
CHART_ROWS_MAX,
11+
type ChartSpec,
12+
mapRowsToColumnNames,
13+
parseChartSpec,
14+
shapeTableRows,
15+
} from '@/lib/charts/spec'
1016
import { useTable, useTableRowsSample } from '@/hooks/queries/tables'
1117
import { PreviewLoadingFrame } from './preview-shared'
1218

13-
/** Hard cap on rows a table-backed chart pulls; the spec's `limit` clamps under it. */
14-
const CHART_ROWS_MAX = 5000
15-
const CHART_ROWS_DEFAULT = 1000
16-
1719
function buildOption(spec: ChartSpec, rows: Array<Record<string, unknown>> | null): EChartsOption {
1820
return buildChartRenderOption({ title: spec.title, option: spec.option, rows }) as EChartsOption
1921
}
2022

23+
function ChartErrorCard({ message, content }: { message: string; content: string }) {
24+
return (
25+
<div className='overflow-hidden rounded-lg border border-[var(--border)]'>
26+
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
27+
<span className='text-[11px] text-[var(--text-tertiary)]'>chart</span>
28+
<span className='text-[11px] text-[var(--text-muted)]'>{message}</span>
29+
</div>
30+
<div className='code-editor-theme bg-[var(--surface-5)]'>
31+
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] text-[var(--text-primary)] leading-[1.6]'>
32+
<code>{content}</code>
33+
</pre>
34+
</div>
35+
</div>
36+
)
37+
}
38+
2139
function ChartErrorPanel({ message, content }: { message: string; content: string }) {
2240
return (
2341
<div className='min-h-0 flex-1 overflow-auto p-6'>
24-
<div className='overflow-hidden rounded-lg border border-[var(--border)]'>
25-
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
26-
<span className='text-[11px] text-[var(--text-tertiary)]'>chart</span>
27-
<span className='text-[11px] text-[var(--text-muted)]'>{message}</span>
28-
</div>
29-
<div className='code-editor-theme bg-[var(--surface-5)]'>
30-
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] text-[var(--text-primary)] leading-[1.6]'>
31-
<code>{content}</code>
32-
</pre>
33-
</div>
34-
</div>
42+
<ChartErrorCard message={message} content={content} />
3543
</div>
3644
)
3745
}
@@ -92,20 +100,7 @@ export const ChartPreview = memo(function ChartPreview({
92100
const fetched = rowsQuery.data?.rows
93101
const columns = tableQuery.data?.schema.columns
94102
if (!fetched || !columns) return null
95-
// Row data is stored keyed by column ID (an opaque uuid); chart specs —
96-
// like the mothership table tool and the public API — speak column NAMES.
97-
// Remap so `encode`/dimension references in the option match what the
98-
// author sees in the table UI.
99-
const nameByStorageKey = new Map<string, string>()
100-
for (const col of columns) nameByStorageKey.set(getColumnId(col), col.name)
101-
const named = fetched.map((row) => {
102-
const out: Record<string, unknown> = {}
103-
for (const [key, value] of Object.entries(row.data)) {
104-
out[nameByStorageKey.get(key) ?? key] = value
105-
}
106-
return out
107-
})
108-
return shapeTableRows(named, tableSource)
103+
return shapeTableRows(mapRowsToColumnNames(fetched, columns), tableSource)
109104
}, [spec, tableSource, rowsQuery.data, tableQuery.data])
110105

111106
const option = useMemo(() => (spec ? buildOption(spec, rows) : null), [spec, rows])
@@ -141,7 +136,6 @@ export const ChartPreview = memo(function ChartPreview({
141136
return <ChartErrorPanel message={parseError} content={content} />
142137
}
143138
if (loadError) return <ChartErrorPanel message={loadError} content={content} />
144-
if (renderError) return <ChartErrorPanel message={renderError} content={content} />
145139
if (tableSource && rowsQuery.isError) {
146140
return (
147141
<ChartErrorPanel
@@ -164,9 +158,16 @@ export const ChartPreview = memo(function ChartPreview({
164158
// Width-driven aspect box, not full-bleed: a chart stretched to the whole
165159
// panel height is unreadable in a tall resource pane. ECharts follows the
166160
// box through the ResizeObserver above.
161+
//
162+
// A render error (setOption threw) HIDES the chart box rather than
163+
// unmounting it: the render effect only re-runs when the option changes,
164+
// and it needs the container mounted at that moment to re-initialize —
165+
// an unmounted container would leave the fixed chart blank until a
166+
// second edit.
167167
return (
168168
<div className='min-h-0 flex-1 overflow-auto p-6'>
169-
<div className='relative mx-auto w-full max-w-[1024px]'>
169+
{renderError !== null && <ChartErrorCard message={renderError} content={content} />}
170+
<div className={renderError !== null ? 'hidden' : 'relative mx-auto w-full max-w-[1024px]'}>
170171
{(!echartsLib || waitingOnRows) && (
171172
<PreviewLoadingFrame className='absolute inset-0 z-10' />
172173
)}

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ import { ZoomablePreview } from './zoomable-preview'
2424
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | 'chart' | null
2525

2626
const PREVIEWABLE_MIME_TYPES: Record<string, PreviewType> = {
27+
// Sim pages store an EXTENSIONLESS name — without this mapping the record
28+
// type resolves to no preview at all and the viewer renders a blank pane
29+
// (including the live compile-as-it-streams view).
30+
'text/x-sim-page': 'html',
2731
'text/markdown': 'markdown',
2832
'text/html': 'html',
29-
'text/x-sim-page': 'html',
3033
'text/csv': 'csv',
3134
'image/svg+xml': 'svg',
3235
'text/x-mermaid': 'mermaid',
@@ -187,7 +190,7 @@ export function buildHtmlPreviewDocument(
187190
// token overrides follow the sheet so the app's live values beat its
188191
// fallbacks, and the shell follows both.
189192
usesSimArtifactStyles(content)
190-
? `<style>${SIM_ARTIFACT_STYLESHEET}</style><style>${simTokenOverrides()}</style>${SIM_ARTIFACT_SHELL}`
193+
? `<style>${SIM_ARTIFACT_STYLESHEET}</style><style>${simTokenOverrides(theme)}</style>${SIM_ARTIFACT_SHELL}`
191194
: '',
192195
HTML_PREVIEW_BOOTSTRAP,
193196
].join('')
@@ -347,6 +350,16 @@ const HtmlPreview = memo(function HtmlPreview({
347350
if (href.startsWith('/workspace/')) {
348351
router.push(href)
349352
} else if (/^https?:\/\//i.test(href)) {
353+
// The server-compiled document absolutizes workspace links (so a
354+
// DOWNLOADED copy reaches Sim) — recognize our own origin and route
355+
// in-app rather than spawning a new tab of the whole app.
356+
try {
357+
const url = new URL(href)
358+
if (url.origin === window.location.origin && url.pathname.startsWith('/workspace/')) {
359+
router.push(`${url.pathname}${url.search}${url.hash}`)
360+
return
361+
}
362+
} catch {}
350363
window.open(href, '_blank', 'noopener,noreferrer')
351364
}
352365
}

apps/sim/lib/charts/fence.ts

Lines changed: 0 additions & 121 deletions
This file was deleted.

apps/sim/lib/charts/option.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/**
2-
* Shared ECharts option assembly for Sim chart surfaces — the `.chart` file
3-
* viewer (client) and the sim-page `sim:chart` SSR renderer (server). Pure:
4-
* no React, no echarts import, JSON-in/JSON-out.
2+
* ECharts option assembly for the `.chart` file viewer. Pure: no React, no
3+
* echarts import, JSON-in/JSON-out.
54
*
65
* Chrome layout is Sim-owned, content is spec-owned. Models reliably produce
76
* colliding title/legend placements, so the renderer pins the title top-left

0 commit comments

Comments
 (0)