@malloy-publisher/sdk 0.0.228 → 0.0.229

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/{ServerProvider-BnQo3hhW.es.js → ServerProvider-BdQK_q3C.es.js} +780 -715
  2. package/dist/ServerProvider-n4SO1thp.cjs.js +1 -0
  3. package/dist/client/api.d.ts +79 -1
  4. package/dist/client/index.cjs.js +1 -1
  5. package/dist/client/index.es.js +1 -1
  6. package/dist/components/Materializations/CreateEnvMaterializationDialog.d.ts +14 -0
  7. package/dist/components/Materializations/EnvironmentMaterializations.d.ts +7 -0
  8. package/dist/components/Materializations/MaterializationDetailDialog.d.ts +2 -1
  9. package/dist/components/Materializations/MaterializationRunsList.d.ts +5 -1
  10. package/dist/components/Materializations/ScheduleCard.d.ts +20 -0
  11. package/dist/components/Materializations/SectionLabel.d.ts +10 -0
  12. package/dist/components/Materializations/SetScheduleDialog.d.ts +11 -0
  13. package/dist/components/Materializations/TriggerChip.d.ts +10 -0
  14. package/dist/components/Materializations/cron.d.ts +23 -0
  15. package/dist/components/Materializations/index.d.ts +1 -0
  16. package/dist/components/Materializations/utils.d.ts +9 -0
  17. package/dist/components/Notebook/Notebook.d.ts +6 -1
  18. package/dist/components/Notebook/NotebookCell.d.ts +3 -1
  19. package/dist/components/RenderedResult/ResultContainer.d.ts +3 -1
  20. package/dist/components/RenderedResult/renderLogs.d.ts +6 -0
  21. package/dist/components/ResultsDialog.d.ts +3 -1
  22. package/dist/constants/docLinks.d.ts +6 -0
  23. package/dist/{core-DYlbkyc-.cjs.js → core-BJUrppec.cjs.js} +1 -1
  24. package/dist/{core-CEDZMHV1.es.js → core-Coi3caGs.es.js} +1 -1
  25. package/dist/index-DWcuaQOW.cjs.js +309 -0
  26. package/dist/{index-DC7gIcJX.es.js → index-mbDUet0T.es.js} +34625 -25301
  27. package/dist/index.cjs.js +1 -1
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.es.js +54 -52
  30. package/dist/utils/queryClient.d.ts +5 -0
  31. package/package.json +3 -1
  32. package/src/components/Environment/AddPackageDialog.tsx +2 -1
  33. package/src/components/Environment/Environment.tsx +8 -0
  34. package/src/components/Home/Home.tsx +5 -4
  35. package/src/components/Materializations/CreateEnvMaterializationDialog.tsx +176 -0
  36. package/src/components/Materializations/EnvironmentMaterializations.tsx +264 -0
  37. package/src/components/Materializations/ManifestView.tsx +2 -3
  38. package/src/components/Materializations/MaterializationDetailDialog.tsx +121 -95
  39. package/src/components/Materializations/MaterializationRunsList.tsx +37 -0
  40. package/src/components/Materializations/Materializations.tsx +86 -1
  41. package/src/components/Materializations/ScheduleCard.tsx +228 -0
  42. package/src/components/Materializations/SectionLabel.tsx +27 -0
  43. package/src/components/Materializations/SetScheduleDialog.tsx +159 -0
  44. package/src/components/Materializations/TriggerChip.tsx +41 -0
  45. package/src/components/Materializations/cron.ts +121 -0
  46. package/src/components/Materializations/index.ts +1 -0
  47. package/src/components/Materializations/utils.ts +14 -0
  48. package/src/components/Model/ModelCell.tsx +4 -0
  49. package/src/components/Notebook/Notebook.tsx +14 -1
  50. package/src/components/Notebook/NotebookCell.tsx +103 -2
  51. package/src/components/Package/Package.tsx +14 -3
  52. package/src/components/QueryResult/QueryResult.tsx +7 -1
  53. package/src/components/RenderedResult/RenderedResult.tsx +153 -51
  54. package/src/components/RenderedResult/ResultContainer.tsx +40 -1
  55. package/src/components/RenderedResult/renderLogs.spec.ts +55 -0
  56. package/src/components/RenderedResult/renderLogs.ts +40 -0
  57. package/src/components/ResultsDialog.tsx +4 -0
  58. package/src/constants/docLinks.ts +17 -0
  59. package/src/index.ts +1 -0
  60. package/src/utils/queryClient.ts +14 -0
  61. package/dist/ServerProvider-CCKhmHeT.cjs.js +0 -1
  62. package/dist/index-DO6Y8XP_.cjs.js +0 -307
@@ -69,6 +69,13 @@ export interface MaterializationMetadata {
69
69
  forceRefresh?: boolean;
70
70
  sourceNames?: string[];
71
71
  mode?: "auto" | "orchestrated";
72
+ /**
73
+ * What initiated the run. `SCHEDULER` = the standalone scheduler fired the
74
+ * package's `materialization.schedule` cron; `ON_DEMAND` (default) = an
75
+ * explicit API/UI create. Recorded on the run so a scheduled rebuild is
76
+ * distinguishable from a manual one.
77
+ */
78
+ trigger?: "ON_DEMAND" | "SCHEDULER";
72
79
  sourcesBuilt?: number;
73
80
  sourcesReused?: number;
74
81
  }
@@ -79,6 +86,13 @@ export function parseMetadata(
79
86
  return (materialization.metadata ?? {}) as MaterializationMetadata;
80
87
  }
81
88
 
89
+ /** Human label for how a run was initiated. */
90
+ export function triggerLabel(
91
+ meta: MaterializationMetadata,
92
+ ): "Scheduled" | "Manual" {
93
+ return meta.trigger === "SCHEDULER" ? "Scheduled" : "Manual";
94
+ }
95
+
82
96
  /** Human-readable elapsed time between two ISO timestamps (to now if open). */
83
97
  export function formatDuration(
84
98
  startedAt?: string | null,
@@ -5,6 +5,7 @@ import React, { useEffect } from "react";
5
5
  import { useQueryWithApiError } from "../../hooks/useQueryWithApiError";
6
6
  import { usePublisherTheme } from "../../theme/ThemeContext";
7
7
  import { parseResourceUri } from "../../utils/formatting";
8
+ import { CHART_RESULT_QUERY_OPTIONS } from "../../utils/queryClient";
8
9
  import { highlight } from "../highlighter";
9
10
  import ResultContainer from "../RenderedResult/ResultContainer";
10
11
  import ResultsDialog from "../ResultsDialog";
@@ -56,6 +57,7 @@ export function ModelCell({
56
57
  },
57
58
  ),
58
59
  enabled: runOnDemand ? hasRun : true, // Execute on demand or always
60
+ ...CHART_RESULT_QUERY_OPTIONS,
59
61
  });
60
62
 
61
63
  const { mode } = usePublisherTheme();
@@ -179,6 +181,7 @@ export function ModelCell({
179
181
  result={queryData.data.result}
180
182
  maxHeight={600}
181
183
  maxResultSize={maxResultSize}
184
+ renderLogs={queryData.data.renderLogs}
182
185
  />
183
186
  )}
184
187
  </CleanMetricCard>
@@ -188,6 +191,7 @@ export function ModelCell({
188
191
  open={resultsDialogOpen}
189
192
  onClose={() => setResultsDialogOpen(false)}
190
193
  result={queryData?.data?.result || ""}
194
+ renderLogs={queryData?.data?.renderLogs}
191
195
  title={`Query: ${queryName}`}
192
196
  />
193
197
  </CleanNotebookCell>
@@ -1,7 +1,14 @@
1
1
  import "@malloydata/malloy-explorer/styles.css";
2
2
  import * as Malloy from "@malloydata/malloy-interfaces";
3
3
  import { Box, Paper, Stack, Typography } from "@mui/material";
4
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import {
5
+ MouseEvent,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
5
12
  import { RawNotebook, Source } from "../../client";
6
13
  import {
7
14
  getDimensionKey,
@@ -37,6 +44,10 @@ interface NotebookProps {
37
44
  maxResultSize?: number;
38
45
  /** Optional retrieval function for semantic search filters */
39
46
  retrievalFn?: RetrievalFunction;
47
+ /** Optional SPA navigation handler for links inside the notebook. When
48
+ * omitted, in-notebook links fall back to plain absolute anchors, so no
49
+ * react-router context is required to render a notebook. */
50
+ onNavigate?: (to: string, event?: MouseEvent) => void;
40
51
  }
41
52
 
42
53
  // Requires PackageProvider
@@ -44,6 +55,7 @@ export default function Notebook({
44
55
  resourceUri,
45
56
  maxResultSize = 0,
46
57
  retrievalFn,
58
+ onNavigate,
47
59
  }: NotebookProps) {
48
60
  const { apiClients } = useServer();
49
61
  const {
@@ -593,6 +605,7 @@ export default function Notebook({
593
605
  resourceUri={resourceUri}
594
606
  maxResultSize={maxResultSize}
595
607
  isExecuting={isExecuting}
608
+ onNavigate={onNavigate}
596
609
  />
597
610
  ))}
598
611
 
@@ -18,6 +18,7 @@ import {
18
18
  import Markdown from "markdown-to-jsx";
19
19
  import React, { useEffect, useState } from "react";
20
20
  import { usePublisherTheme } from "../../theme/ThemeContext";
21
+ import { parseResourceUri } from "../../utils/formatting";
21
22
  import { highlight } from "../highlighter";
22
23
  import { ModelExplorerDialog } from "../Model/ModelExplorerDialog";
23
24
  import { createEmbeddedQueryResult } from "../QueryResult/QueryResult";
@@ -36,6 +37,83 @@ interface NotebookCellProps {
36
37
  index: number;
37
38
  maxResultSize?: number;
38
39
  isExecuting?: boolean;
40
+ onNavigate?: (to: string, event?: React.MouseEvent) => void;
41
+ }
42
+
43
+ interface NotebookMarkdownLinkProps {
44
+ href?: string;
45
+ title?: string;
46
+ children?: React.ReactNode;
47
+ envName: string;
48
+ pkgName: string;
49
+ sourceDir: string;
50
+ onNavigate?: (to: string, event?: React.MouseEvent) => void;
51
+ }
52
+
53
+ // Links inside a rendered notebook/README are authored relative to the source
54
+ // file (e.g. `spielberg.malloynb`). A plain browser anchor resolves them
55
+ // against the current page URL, which drops the package segment for a README
56
+ // shown at the package route. Resolve relative links against the source file's
57
+ // own directory instead and route them through the SPA. External, hash, and
58
+ // absolute links stay as normal anchors.
59
+ function NotebookMarkdownLink({
60
+ href,
61
+ title,
62
+ children,
63
+ envName,
64
+ pkgName,
65
+ sourceDir,
66
+ onNavigate,
67
+ }: NotebookMarkdownLinkProps) {
68
+ const hasScheme = !!href && /^[a-z][a-z0-9+.-]*:/i.test(href);
69
+ const isInternalRelative =
70
+ !!href && !hasScheme && !href.startsWith("/") && !href.startsWith("#");
71
+
72
+ if (!isInternalRelative || !href) {
73
+ // External, absolute, or hash link: render a normal anchor. Only allow
74
+ // safe schemes so a crafted `javascript:`/`data:` href in a package
75
+ // README (packages can come from untrusted git/S3 sources) cannot run.
76
+ const isUnsafeScheme =
77
+ hasScheme && !/^(https?|mailto|tel):/i.test(href ?? "");
78
+ return (
79
+ <a
80
+ href={isUnsafeScheme ? undefined : href}
81
+ title={title}
82
+ {...(hasScheme
83
+ ? { target: "_blank", rel: "noopener noreferrer" }
84
+ : {})}
85
+ >
86
+ {children}
87
+ </a>
88
+ );
89
+ }
90
+
91
+ // Resolve the relative href against the source file's directory (empty for a
92
+ // package-root README) via a dummy origin, which normalizes any `./`/`../`
93
+ // and clamps escapes at the package root, then prefix the package path.
94
+ const resolved = new URL(href, `https://malloy.invalid/${sourceDir}`);
95
+ const packageRelative =
96
+ resolved.pathname.slice(1) + resolved.search + resolved.hash;
97
+ const to = `/${envName}/${pkgName}/${packageRelative}`;
98
+ // With a host router (onNavigate) intercept and route through the SPA.
99
+ // Without one (router-free SDK embedding) the absolute href navigates on
100
+ // its own, so rendering a notebook needs no react-router context.
101
+ return (
102
+ <a
103
+ href={to}
104
+ title={title}
105
+ onClick={
106
+ onNavigate
107
+ ? (event) => {
108
+ event.preventDefault();
109
+ onNavigate(to, event);
110
+ }
111
+ : undefined
112
+ }
113
+ >
114
+ {children}
115
+ </a>
116
+ );
39
117
  }
40
118
 
41
119
  export function NotebookCell({
@@ -46,6 +124,7 @@ export function NotebookCell({
46
124
  index,
47
125
  maxResultSize,
48
126
  isExecuting,
127
+ onNavigate,
49
128
  }: NotebookCellProps) {
50
129
  const [codeDialogOpen, setCodeDialogOpen] = React.useState<boolean>(false);
51
130
  const [embeddingDialogOpen, setEmbeddingDialogOpen] =
@@ -61,6 +140,28 @@ export function NotebookCell({
61
140
 
62
141
  const [copyMessage, setCopyMessage] = useState("");
63
142
 
143
+ const { environmentName, packageName, modelPath } =
144
+ parseResourceUri(resourceUri);
145
+ // Directory of the source file within the package (empty for a package-root
146
+ // README), used to resolve relative links the author wrote against it.
147
+ const sourceDir =
148
+ modelPath && modelPath.includes("/")
149
+ ? modelPath.slice(0, modelPath.lastIndexOf("/") + 1)
150
+ : "";
151
+ const markdownOptions = {
152
+ overrides: {
153
+ a: {
154
+ component: NotebookMarkdownLink,
155
+ props: {
156
+ envName: environmentName,
157
+ pkgName: packageName,
158
+ sourceDir,
159
+ onNavigate,
160
+ },
161
+ },
162
+ },
163
+ };
164
+
64
165
  // Regex to extract imported names from import statements
65
166
  const IMPORT_NAMES_REGEX = /import\s*\{([^}]+)\}\s*from\s*['"`][^'"`]+['"`]/;
66
167
 
@@ -190,7 +291,7 @@ export function NotebookCell({
190
291
  alignItems="flex-start"
191
292
  justifyContent="space-between"
192
293
  >
193
- <Markdown>{cell.text}</Markdown>
294
+ <Markdown options={markdownOptions}>{cell.text}</Markdown>
194
295
  <Tooltip title="Click to copy link">
195
296
  <LinkOutlinedIcon
196
297
  sx={{
@@ -204,7 +305,7 @@ export function NotebookCell({
204
305
  </Tooltip>
205
306
  </Stack>
206
307
  ) : (
207
- <Markdown>{cell.text}</Markdown>
308
+ <Markdown options={markdownOptions}>{cell.text}</Markdown>
208
309
  )}
209
310
  <Snackbar
210
311
  open={copyMessage !== ""}
@@ -215,7 +215,12 @@ export default function Package({
215
215
  icon={<ContentTypeIcon type="report" />}
216
216
  tint={MALLOY_BRAND.teal}
217
217
  label={notebook.path}
218
- onClick={(event) => onClick(notebook.path, event)}
218
+ onClick={(event) =>
219
+ onClick(
220
+ `/${environmentName}/${packageName}/${notebook.path}`,
221
+ event,
222
+ )
223
+ }
219
224
  />
220
225
  ))}
221
226
  {notebooks.length === 0 && <EmptyRow label="No notebooks" />}
@@ -248,7 +253,7 @@ export default function Package({
248
253
  // The `pages/` prefix lets the router branch
249
254
  // off the existing model-path catch-all.
250
255
  onClickPackageFile(
251
- `pages/${page.path}`,
256
+ `/${environmentName}/${packageName}/pages/${page.path}`,
252
257
  event,
253
258
  );
254
259
  } else {
@@ -293,7 +298,12 @@ export default function Package({
293
298
  icon={<ContentTypeIcon type="model" />}
294
299
  tint={MALLOY_BRAND.orange}
295
300
  label={model.path}
296
- onClick={(event) => onClick(model.path, event)}
301
+ onClick={(event) =>
302
+ onClick(
303
+ `/${environmentName}/${packageName}/${model.path}`,
304
+ event,
305
+ )
306
+ }
297
307
  />
298
308
  ))}
299
309
  {models.length === 0 && <EmptyRow label="No models" />}
@@ -332,6 +342,7 @@ export default function Package({
332
342
  <Notebook
333
343
  resourceUri={readmeResourceUri}
334
344
  retrievalFn={retrievalFn}
345
+ onNavigate={onClick}
335
346
  />
336
347
  </Box>
337
348
  )}
@@ -1,6 +1,7 @@
1
1
  import { Suspense } from "react";
2
2
  import { useQueryWithApiError } from "../../hooks/useQueryWithApiError";
3
3
  import { parseResourceUri } from "../../utils/formatting";
4
+ import { CHART_RESULT_QUERY_OPTIONS } from "../../utils/queryClient";
4
5
  import { ApiErrorDisplay } from "../ApiErrorDisplay";
5
6
  import { Loading } from "../Loading";
6
7
  import ResultContainer from "../RenderedResult/ResultContainer";
@@ -90,6 +91,7 @@ export default function QueryResult({
90
91
  versionId: versionId,
91
92
  },
92
93
  ),
94
+ ...CHART_RESULT_QUERY_OPTIONS,
93
95
  });
94
96
 
95
97
  return (
@@ -99,7 +101,11 @@ export default function QueryResult({
99
101
  )}
100
102
  {isSuccess && (
101
103
  <Suspense fallback={<div>Loading...</div>}>
102
- <ResultContainer result={data.data.result} maxHeight={height} />
104
+ <ResultContainer
105
+ result={data.data.result}
106
+ maxHeight={height}
107
+ renderLogs={data.data.renderLogs}
108
+ />
103
109
  </Suspense>
104
110
  )}
105
111
  {isError && (
@@ -1,5 +1,5 @@
1
1
  import { Box } from "@mui/material";
2
- import React, { Suspense, useLayoutEffect, useRef } from "react";
2
+ import React, { Suspense, useEffect, useLayoutEffect, useRef } from "react";
3
3
  import { buildMalloyExplicitTheme } from "../../theme/buildMalloyExplicitTheme";
4
4
  import { buildTableCssVars } from "../../theme/buildTableCssVars";
5
5
  import { buildVegaThemeOverride } from "../../theme/buildVegaThemeOverride";
@@ -20,6 +20,10 @@ interface MalloyVizHandle {
20
20
  setResult: (result: unknown) => void;
21
21
  render: (element: HTMLElement) => void;
22
22
  remove: () => void;
23
+ // Fires once the chart has painted (or immediately if it already has). We
24
+ // use it to swap a freshly-rendered chart in only when it's ready, so the
25
+ // previously-painted chart never blanks out mid-render.
26
+ onReady: (callback: () => void) => void;
23
27
  }
24
28
 
25
29
  declare global {
@@ -67,6 +71,13 @@ const createRenderer = async (
67
71
  return renderer.createViz() as MalloyVizHandle;
68
72
  };
69
73
 
74
+ // Warm the renderer chunk as soon as this module loads so the first chart
75
+ // paint doesn't have to wait on the dynamic import resolving (the async
76
+ // import is what widened the clear-then-repaint gap into a visible flicker).
77
+ if (typeof window !== "undefined") {
78
+ void import("@malloydata/render");
79
+ }
80
+
70
81
  /**
71
82
  * Pull a per-chart Theme override out of a parsed Malloy result by reading
72
83
  * `# theme.*` annotations on the model and the query. Returns `undefined`
@@ -243,39 +254,59 @@ function RenderedResultInner({
243
254
  }: RenderedResultProps) {
244
255
  const ref = useRef<HTMLDivElement>(null);
245
256
  const hasMeasuredRef = useRef(false);
257
+ // The chart currently painted into the container, held across renders. A
258
+ // new render paints into a fresh offscreen stage and only swaps in (and
259
+ // disposes this one) once it's ready, so the container never blanks between
260
+ // charts. That up-front clear + cleanup dispose was the flicker.
261
+ const liveRef = useRef<{ viz: MalloyVizHandle; node: HTMLElement } | null>(
262
+ null,
263
+ );
264
+ // Bumped on every render-effect run and on unmount. A slow async render that
265
+ // resolves after a newer one has started (or after unmount) checks this and
266
+ // bails, so overlapping renders can't leave two charts or leak a viz.
267
+ const renderGenRef = useRef(0);
246
268
  const { theme: baseTheme, layers, mode } = usePublisherTheme();
247
269
 
270
+ // Dispose the last live viz on unmount only. Deliberately NOT done in the
271
+ // render effect's cleanup: a re-run must keep the old chart until the new
272
+ // one has painted, and the new render disposes it during the swap.
273
+ useEffect(() => {
274
+ return () => {
275
+ renderGenRef.current += 1;
276
+ liveRef.current?.viz.remove();
277
+ liveRef.current = null;
278
+ };
279
+ }, []);
280
+
248
281
  useLayoutEffect(() => {
249
282
  if (!ref.current || !result) return;
250
283
 
251
284
  injectRendererOverrides();
252
285
 
253
- let isMounted = true;
254
- // Track the active viz instance so the cleanup can dispose it even
255
- // when unmount races with the async setup (see bug #3 from the code
256
- // review: viz constructed but never `remove()`'d on rapid navigation).
257
- let viz: MalloyVizHandle | undefined;
258
286
  const element = ref.current;
259
-
260
- while (element.firstChild) {
261
- element.removeChild(element.firstChild);
262
- }
263
-
264
- // Apply the shell theme's CSS variables immediately so the table
265
- // chrome looks right during the async setup window. We re-apply once
266
- // the per-chart override resolves below, so per-chart annotations
267
- // affect both Vega and table styles.
268
- applyTableCssVars(element, baseTheme);
269
-
270
- hasMeasuredRef.current = false;
271
-
287
+ const myGen = (renderGenRef.current += 1);
288
+ let cancelled = false;
289
+ const isCurrent = () => !cancelled && myGen === renderGenRef.current;
290
+ // Created inside the async body; hoisted so cleanup can tear them down
291
+ // if this render is superseded before it swaps itself into `liveRef`.
292
+ let stage: HTMLDivElement | undefined;
293
+ let viz: MalloyVizHandle | undefined;
272
294
  let observer: MutationObserver | null = null;
273
295
  let measureTimeout: NodeJS.Timeout | null = null;
296
+ // Safety net so a render that never signals ready (an async renderer
297
+ // error that only reaches onError) can't leave a previous chart showing
298
+ // stale data forever; see the setTimeout below.
299
+ let readyFallback: ReturnType<typeof setTimeout> | null = null;
300
+
301
+ hasMeasuredRef.current = false;
274
302
 
275
- const measureRenderedSize = () => {
276
- if (hasMeasuredRef.current || !isMounted || !element.firstElementChild)
303
+ // Measure the rendered chart's natural height off `root` (the stage that
304
+ // wraps the renderer output) and report it up. Same grandchild/dashboard
305
+ // HACK as before, just anchored on the stage wrapper.
306
+ const measureRenderedSize = (root: HTMLElement) => {
307
+ if (hasMeasuredRef.current || cancelled || !root.firstElementChild)
277
308
  return;
278
- const child = element.firstElementChild as HTMLElement;
309
+ const child = root.firstElementChild as HTMLElement;
279
310
  const grandchild = child.firstElementChild as HTMLElement;
280
311
  if (!grandchild) return;
281
312
  const greatgrandchild = grandchild.firstElementChild as HTMLElement;
@@ -315,52 +346,123 @@ function RenderedResultInner({
315
346
  ? resolveTheme([...layers, perChart], mode)
316
347
  : baseTheme;
317
348
 
318
- // Per-chart annotation may have shifted table CSS vars; re-apply.
319
- if (isMounted) applyTableCssVars(element, effectiveTheme);
349
+ if (!isCurrent()) return;
320
350
 
321
351
  try {
322
- const created = await createRenderer(effectiveTheme, onDrill);
323
- if (!isMounted) {
324
- // Unmounted during the dynamic import / construction. Tear
325
- // down the viz we just made; the effect's cleanup won't see
326
- // it because `viz` was unset.
327
- created.remove();
328
- return;
352
+ viz = await createRenderer(effectiveTheme, onDrill);
353
+ } catch (error) {
354
+ console.error("Failed to create renderer:", error);
355
+ return;
356
+ }
357
+ if (!isCurrent()) {
358
+ // Superseded during the dynamic import / construction.
359
+ viz.remove();
360
+ viz = undefined;
361
+ return;
362
+ }
363
+
364
+ // Render into a fresh stage appended to the container. While a
365
+ // previous chart is still on screen, keep the stage laid out (so the
366
+ // fill chart actually measures a size and `onReady` fires) but hidden
367
+ // and overlaid; reveal it and drop the old chart only once painted.
368
+ const previous = liveRef.current;
369
+ stage = document.createElement("div");
370
+ stage.style.width = "100%";
371
+ stage.style.height = "100%";
372
+ // Theme the chart's table/dashboard chrome on the stage itself, not on
373
+ // the shared container: during a swap the outgoing chart is still a
374
+ // child of the container, so writing the new per-chart CSS vars there
375
+ // would repaint the old chart's chrome to the new theme before it is
376
+ // swapped out. Scoping the vars to this stage keeps each chart stable.
377
+ applyTableCssVars(stage, effectiveTheme);
378
+ if (previous) {
379
+ element.style.position = "relative";
380
+ stage.style.position = "absolute";
381
+ stage.style.inset = "0";
382
+ stage.style.visibility = "hidden";
383
+ }
384
+ element.appendChild(stage);
385
+
386
+ // Fallback measurement if `onReady` never fires: settle on DOM
387
+ // mutations, then measure once.
388
+ const stageNode = stage;
389
+ observer = new MutationObserver(() => {
390
+ if (measureTimeout) clearTimeout(measureTimeout);
391
+ measureTimeout = setTimeout(() => {
392
+ measureRenderedSize(stageNode);
393
+ observer?.disconnect();
394
+ }, 100);
395
+ });
396
+ observer.observe(stage, {
397
+ childList: true,
398
+ subtree: true,
399
+ attributes: true,
400
+ });
401
+
402
+ const activeViz = viz;
403
+ let promoted = false;
404
+ const promote = () => {
405
+ if (promoted || !isCurrent()) return;
406
+ promoted = true;
407
+ if (readyFallback) {
408
+ clearTimeout(readyFallback);
409
+ readyFallback = null;
329
410
  }
330
- viz = created;
331
-
332
- observer = new MutationObserver(() => {
333
- if (measureTimeout) clearTimeout(measureTimeout);
334
- measureTimeout = setTimeout(() => {
335
- measureRenderedSize();
336
- observer?.disconnect();
337
- }, 100);
338
- });
339
-
340
- observer.observe(element, {
341
- childList: true,
342
- subtree: true,
343
- attributes: true,
344
- });
411
+ // The new chart has painted; drop the outgoing one now.
412
+ if (previous) {
413
+ previous.viz.remove();
414
+ if (previous.node.parentNode === element) {
415
+ element.removeChild(previous.node);
416
+ }
417
+ }
418
+ stageNode.style.position = "";
419
+ stageNode.style.inset = "";
420
+ stageNode.style.visibility = "";
421
+ element.style.position = "";
422
+ liveRef.current = { viz: activeViz, node: stageNode };
423
+ measureRenderedSize(stageNode);
424
+ };
345
425
 
426
+ try {
346
427
  // The renderer accepts a Malloy Result; we don't import that type
347
428
  // in the SDK to avoid pinning to the malloy core types here.
348
429
  viz.setResult(parsed);
349
- viz.render(element);
430
+ viz.render(stage);
431
+ viz.onReady(promote);
432
+ // If onReady never fires (an async render error that only reaches
433
+ // the renderer's onError), force the swap after a bounded wait so
434
+ // the outcome becomes visible instead of the previous chart
435
+ // lingering with stale data indefinitely.
436
+ readyFallback = setTimeout(promote, 10000);
350
437
  } catch (error) {
351
438
  console.error("Error rendering visualization:", error);
352
439
  observer?.disconnect();
353
- viz?.remove();
440
+ viz.remove();
354
441
  viz = undefined;
442
+ if (stageNode.parentNode === element) {
443
+ element.removeChild(stageNode);
444
+ }
355
445
  }
356
446
  })();
357
447
 
358
448
  return () => {
359
- isMounted = false;
449
+ cancelled = true;
360
450
  observer?.disconnect();
361
451
  if (measureTimeout) clearTimeout(measureTimeout);
362
- viz?.remove();
363
- viz = undefined;
452
+ if (readyFallback) clearTimeout(readyFallback);
453
+ // If this render built a stage but never swapped it into `liveRef`
454
+ // (superseded, or unmounted mid-render), tear it down so it can't
455
+ // leak a viz or leave an orphan node behind.
456
+ if (stage && liveRef.current?.node !== stage) {
457
+ viz?.remove();
458
+ if (stage.parentNode === element) {
459
+ element.removeChild(stage);
460
+ }
461
+ // This render may have set the container position:relative for its
462
+ // overlay stage but never promoted; reset it so no stray relative
463
+ // lingers on the container.
464
+ element.style.position = "";
465
+ }
364
466
  };
365
467
  }, [result, onDrill, onSizeChange, baseTheme, layers, mode]);
366
468
 
@@ -1,7 +1,10 @@
1
1
  import { Warning } from "@mui/icons-material";
2
- import { Box, Button, Typography } from "@mui/material";
2
+ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
3
+ import { Box, Button, IconButton, Tooltip, Typography } from "@mui/material";
3
4
  import { lazy, Suspense, useRef, useState } from "react";
5
+ import { LogMessage } from "../../client";
4
6
  import { Loading } from "../Loading";
7
+ import { summarizeRenderLogs } from "./renderLogs";
5
8
 
6
9
  const RenderedResult = lazy(() => import("../RenderedResult/RenderedResult"));
7
10
 
@@ -12,6 +15,9 @@ interface ResultContainerProps {
12
15
  // this is to prevent performance issues with large results.
13
16
  // the default is 0, which means no warning will be shown.
14
17
  maxResultSize?: number;
18
+ // Render tag findings from the query response. Callers whose result did not
19
+ // come from a query response have none to pass.
20
+ renderLogs?: LogMessage[];
15
21
  }
16
22
 
17
23
  // ResultContainer is a component that renders a result, with a toggle button to expand/collapse the result.
@@ -22,10 +28,12 @@ export default function ResultContainer({
22
28
  result,
23
29
  maxHeight,
24
30
  maxResultSize = 0,
31
+ renderLogs,
25
32
  }: ResultContainerProps) {
26
33
  const containerRef = useRef<HTMLDivElement>(null);
27
34
  const [measuredHeight, setMeasuredHeight] = useState(maxHeight);
28
35
  const [userAcknowledged, setUserAcknowledged] = useState(false);
36
+ const renderLogSummary = summarizeRenderLogs(renderLogs);
29
37
 
30
38
  if (!result) {
31
39
  return null;
@@ -90,6 +98,37 @@ export default function ResultContainer({
90
98
  />
91
99
  </Suspense>
92
100
  )}
101
+ {renderLogSummary && (
102
+ // Overlaid rather than stacked, so the note cannot change the height
103
+ // the container just measured. The button is what makes the message
104
+ // reachable: an icon alone is aria-hidden and cannot take focus.
105
+ <Box sx={{ position: "absolute", top: 4, right: 4, zIndex: 1 }}>
106
+ <Tooltip
107
+ title={renderLogSummary.title}
108
+ slotProps={{ tooltip: { sx: { whiteSpace: "pre-line" } } }}
109
+ >
110
+ <IconButton
111
+ size="small"
112
+ aria-label="Render tag warnings"
113
+ sx={{
114
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
115
+ "&:hover": {
116
+ backgroundColor: "rgba(255, 255, 255, 1)",
117
+ },
118
+ }}
119
+ >
120
+ <InfoOutlinedIcon
121
+ fontSize="small"
122
+ color={
123
+ renderLogSummary.severity === "error"
124
+ ? "error"
125
+ : "warning"
126
+ }
127
+ />
128
+ </IconButton>
129
+ </Tooltip>
130
+ </Box>
131
+ )}
93
132
  </Box>
94
133
  );
95
134
  }