@marimo-team/frontend 0.24.1-dev30 → 0.24.1-dev31

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.
package/dist/index.html CHANGED
@@ -66,7 +66,7 @@
66
66
  <marimo-server-token data-token="{{ server_token }}" hidden></marimo-server-token>
67
67
  <!-- /TODO -->
68
68
  <title>{{ title }}</title>
69
- <script type="module" crossorigin src="./assets/index-T5Rre8Dj.js"></script>
69
+ <script type="module" crossorigin src="./assets/index-DemYxG9z.js"></script>
70
70
  <link rel="modulepreload" crossorigin href="./assets/preload-helper-BPPi7vOr.js">
71
71
  <link rel="modulepreload" crossorigin href="./assets/objects-CrbstYTV.js">
72
72
  <link rel="modulepreload" crossorigin href="./assets/chunk-C4rtOYze.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/frontend",
3
- "version": "0.24.1-dev30",
3
+ "version": "0.24.1-dev31",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -190,6 +190,62 @@ describe("isInAxes", () => {
190
190
  });
191
191
  });
192
192
 
193
+ // A 100x100 chart whose axes fill the whole figure.
194
+ function createState(): MatplotlibState {
195
+ return {
196
+ ...LINEAR_AXES,
197
+ axesPixelBounds: [0, 0, 100, 100],
198
+ yBounds: [0, 10],
199
+ chartBase64: "first",
200
+ width: 100,
201
+ height: 100,
202
+ selectionColor: "blue",
203
+ selectionOpacity: 0.15,
204
+ strokeWidth: 2,
205
+ debounce: false,
206
+ value: { has_selection: false },
207
+ setValue: vi.fn(),
208
+ };
209
+ }
210
+
211
+ // jsdom has no matchMedia, no animation frames and no canvas context.
212
+ function stubBrowser(ctx: Partial<CanvasRenderingContext2D> | null = null) {
213
+ vi.stubGlobal(
214
+ "matchMedia",
215
+ vi.fn(() => ({ addEventListener: vi.fn() })),
216
+ );
217
+ const requestAnimationFrame = vi.fn(() => 1);
218
+ vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
219
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
220
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
221
+ ctx as CanvasRenderingContext2D | null,
222
+ );
223
+ return { requestAnimationFrame };
224
+ }
225
+
226
+ // jsdom does not fetch images; resolve them synchronously instead.
227
+ function stubSyncImage() {
228
+ vi.stubGlobal(
229
+ "Image",
230
+ class {
231
+ onload: (() => void) | null = null;
232
+ set src(_value: string) {
233
+ this.onload?.();
234
+ }
235
+ },
236
+ );
237
+ }
238
+
239
+ function createRecordingContext() {
240
+ return {
241
+ setTransform: vi.fn(),
242
+ clearRect: vi.fn(),
243
+ drawImage: vi.fn(),
244
+ save: vi.fn(),
245
+ restore: vi.fn(),
246
+ };
247
+ }
248
+
193
249
  describe("MatplotlibRenderer", () => {
194
250
  afterEach(() => {
195
251
  vi.unstubAllGlobals();
@@ -197,36 +253,14 @@ describe("MatplotlibRenderer", () => {
197
253
  });
198
254
 
199
255
  it("restores a new selection when the chart changes", () => {
200
- vi.stubGlobal(
201
- "matchMedia",
202
- vi.fn(() => ({ addEventListener: vi.fn() })),
203
- );
204
- vi.stubGlobal(
205
- "requestAnimationFrame",
206
- vi.fn(() => 1),
207
- );
208
- vi.stubGlobal("cancelAnimationFrame", vi.fn());
209
- vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
256
+ stubBrowser();
210
257
 
211
258
  const value = {
212
259
  type: "box",
213
260
  has_selection: true,
214
261
  data: { x_min: 2, x_max: 4, y_min: 2, y_max: 4 },
215
262
  } as const;
216
- const state: MatplotlibState = {
217
- ...LINEAR_AXES,
218
- axesPixelBounds: [0, 0, 100, 100],
219
- yBounds: [0, 10],
220
- chartBase64: "first",
221
- width: 100,
222
- height: 100,
223
- selectionColor: "blue",
224
- selectionOpacity: 0.15,
225
- strokeWidth: 2,
226
- debounce: false,
227
- value: { has_selection: false },
228
- setValue: vi.fn(),
229
- };
263
+ const state = createState();
230
264
  const container = document.createElement("div");
231
265
  const controller = new AbortController();
232
266
  const renderer = new MatplotlibRenderer(container, {
@@ -252,4 +286,130 @@ describe("MatplotlibRenderer", () => {
252
286
  expect(canvas.style.cursor).toBe("move");
253
287
  controller.abort();
254
288
  });
289
+
290
+ // Safari only fires resize on zoom, not the resolution query (#10625).
291
+ it("re-syncs the backing store on resize, without matchMedia firing", () => {
292
+ stubBrowser();
293
+ vi.stubGlobal("devicePixelRatio", 2);
294
+
295
+ const container = document.createElement("div");
296
+ const controller = new AbortController();
297
+ new MatplotlibRenderer(container, {
298
+ state: createState(),
299
+ signal: controller.signal,
300
+ });
301
+ const canvas = container.querySelector("canvas");
302
+ expect(canvas?.width).toBe(200);
303
+
304
+ vi.stubGlobal("devicePixelRatio", 1);
305
+ window.dispatchEvent(new Event("resize"));
306
+
307
+ expect(canvas?.width).toBe(100);
308
+ controller.abort();
309
+ });
310
+
311
+ // Re-sizing clears the buffer a frame before the repaint, so an over-eager
312
+ // guard flickers. 700 * 1.1 is the case a truncating comparison misses.
313
+ it("leaves the backing store alone when resize fires at an unchanged DPR", () => {
314
+ const { requestAnimationFrame } = stubBrowser();
315
+ vi.stubGlobal("devicePixelRatio", 1.1);
316
+
317
+ const container = document.createElement("div");
318
+ const controller = new AbortController();
319
+ new MatplotlibRenderer(container, {
320
+ state: { ...createState(), width: 700 },
321
+ signal: controller.signal,
322
+ });
323
+ const canvas = container.querySelector("canvas");
324
+ expect(canvas?.width).toBe(770);
325
+
326
+ requestAnimationFrame.mockClear();
327
+ window.dispatchEvent(new Event("resize"));
328
+
329
+ expect(canvas?.width).toBe(770);
330
+ expect(requestAnimationFrame).not.toHaveBeenCalled();
331
+ controller.abort();
332
+ });
333
+
334
+ it("draws with the backing-store scale, not the current devicePixelRatio", () => {
335
+ const ctx = createRecordingContext();
336
+ stubBrowser(ctx);
337
+ stubSyncImage();
338
+ vi.stubGlobal("devicePixelRatio", 2);
339
+
340
+ const state = createState();
341
+ const container = document.createElement("div");
342
+ const controller = new AbortController();
343
+ const renderer = new MatplotlibRenderer(container, {
344
+ state,
345
+ signal: controller.signal,
346
+ });
347
+ expect(container.querySelector("canvas")?.width).toBe(200);
348
+
349
+ // Zoom with nothing re-syncing the buffer: it still holds the old DPR, so
350
+ // the redraw must too, or the image lands scaled by dpr_new / dpr_old.
351
+ vi.stubGlobal("devicePixelRatio", 1);
352
+ ctx.setTransform.mockClear();
353
+ renderer.update({ ...state, strokeWidth: 4 });
354
+
355
+ expect(ctx.setTransform.mock.calls).toEqual([[2, 0, 0, 2, 0, 0]]);
356
+ expect(ctx.drawImage).toHaveBeenLastCalledWith(
357
+ expect.anything(),
358
+ 0,
359
+ 0,
360
+ 100,
361
+ 100,
362
+ );
363
+ controller.abort();
364
+ });
365
+
366
+ it("clears the whole backing store when a re-run swaps the chart", () => {
367
+ const ctx = createRecordingContext();
368
+ stubBrowser(ctx);
369
+ vi.stubGlobal("devicePixelRatio", 2);
370
+
371
+ const state = createState();
372
+ const container = document.createElement("div");
373
+ const controller = new AbortController();
374
+ const renderer = new MatplotlibRenderer(container, {
375
+ state,
376
+ signal: controller.signal,
377
+ });
378
+
379
+ vi.stubGlobal("devicePixelRatio", 1);
380
+ ctx.setTransform.mockClear();
381
+ ctx.clearRect.mockClear();
382
+ renderer.update({ ...state, chartBase64: "second" });
383
+
384
+ // A stale scale here would leave the outer 3/4 of the buffer unwiped.
385
+ expect(ctx.setTransform.mock.calls).toEqual([[2, 0, 0, 2, 0, 0]]);
386
+ expect(ctx.clearRect.mock.calls).toEqual([[0, 0, 100, 100]]);
387
+ controller.abort();
388
+ });
389
+
390
+ it("scales each axis by its own backing-store ratio", () => {
391
+ const ctx = createRecordingContext();
392
+ stubBrowser(ctx);
393
+ stubSyncImage();
394
+ // canvas.width/height are integers, so this truncates the two dimensions
395
+ // by different fractions: 150 * 1.25 -> 187 (0.5 lost), 500 * 1.25 -> 625
396
+ // (exact). One shared, width-derived scale would under-cover the buffer
397
+ // vertically and leave its bottom rows unwiped.
398
+ vi.stubGlobal("devicePixelRatio", 1.25);
399
+
400
+ const state = { ...createState(), width: 150, height: 500 };
401
+ const container = document.createElement("div");
402
+ const controller = new AbortController();
403
+ new MatplotlibRenderer(container, { state, signal: controller.signal });
404
+
405
+ const canvas = container.querySelector("canvas");
406
+ expect(canvas?.width).toBe(187);
407
+ expect(canvas?.height).toBe(625);
408
+ // The pre-load clear and the draw each set up their own transform.
409
+ const transform = [187 / 150, 0, 0, 625 / 500, 0, 0];
410
+ const rect = [0, 0, 150, 500];
411
+ expect(ctx.setTransform.mock.calls).toEqual([transform, transform]);
412
+ expect(ctx.clearRect.mock.calls).toEqual([rect, rect]);
413
+ controller.abort();
414
+ });
255
415
  });
@@ -281,6 +281,8 @@ export class MatplotlibRenderer {
281
281
  #image: HTMLImageElement | null = null;
282
282
  #imageGeneration = 0;
283
283
  #currentChartBase64 = "";
284
+ /** The devicePixelRatio the canvas buffer was last sized with. */
285
+ #backingDpr = 1;
284
286
 
285
287
  constructor(
286
288
  container: HTMLDivElement,
@@ -317,8 +319,12 @@ export class MatplotlibRenderer {
317
319
  });
318
320
 
319
321
  // Watch for devicePixelRatio changes (e.g. browser zoom, moving between
320
- // displays). matchMedia fires exactly once per DPR transition.
322
+ // displays). matchMedia fires exactly once per DPR transition, but Safari
323
+ // doesn't re-evaluate the query on zoom — it only fires resize (#10625).
321
324
  this.#watchDevicePixelRatio(options.signal);
325
+ window.addEventListener("resize", this.#syncForDevicePixelRatio, {
326
+ signal: options.signal,
327
+ });
322
328
 
323
329
  // Clean up on abort
324
330
  options.signal.addEventListener("abort", () => {
@@ -334,6 +340,7 @@ export class MatplotlibRenderer {
334
340
  /** Set the canvas buffer + CSS size to match current logical size and DPR. */
335
341
  #syncCanvasSize(canvas: HTMLCanvasElement = this.#canvas): void {
336
342
  const dpr = globalThis.devicePixelRatio ?? 1;
343
+ this.#backingDpr = dpr;
337
344
  const { width, height } = this.#state;
338
345
  canvas.width = width * dpr;
339
346
  canvas.height = height * dpr;
@@ -343,6 +350,33 @@ export class MatplotlibRenderer {
343
350
  canvas.style.aspectRatio = `${width} / ${height}`;
344
351
  }
345
352
 
353
+ /**
354
+ * Scale to draw at, read back from the buffer rather than from
355
+ * `devicePixelRatio`, so sizing and drawing cannot disagree (#10625).
356
+ * Per-axis: `canvas.width`/`height` are integers, so a fractional DPR
357
+ * truncates each dimension by a different fraction of a device pixel.
358
+ */
359
+ get #backingScale(): { x: number; y: number } {
360
+ const { width, height } = this.#state;
361
+ const dpr = globalThis.devicePixelRatio ?? 1;
362
+ return {
363
+ x: width > 0 ? this.#canvas.width / width : dpr,
364
+ y: height > 0 ? this.#canvas.height / height : dpr,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Re-size the buffer when the DPR changed. resize fires far more often than
370
+ * the DPR moves, and `canvas.width` truncates, so compare ratios directly.
371
+ */
372
+ #syncForDevicePixelRatio = (): void => {
373
+ if ((globalThis.devicePixelRatio ?? 1) === this.#backingDpr) {
374
+ return;
375
+ }
376
+ this.#syncCanvasSize();
377
+ this.#scheduleRedraw();
378
+ };
379
+
346
380
  /**
347
381
  * Observe devicePixelRatio changes via matchMedia. Each listener fires once
348
382
  * per transition, so we re-register after every change.
@@ -355,8 +389,7 @@ export class MatplotlibRenderer {
355
389
  `(resolution: ${globalThis.devicePixelRatio ?? 1}dppx)`,
356
390
  );
357
391
  const onChange = () => {
358
- this.#syncCanvasSize();
359
- this.#drawCanvas();
392
+ this.#syncForDevicePixelRatio();
360
393
  // Re-register for the next DPR transition
361
394
  this.#watchDevicePixelRatio(signal);
362
395
  };
@@ -416,8 +449,8 @@ export class MatplotlibRenderer {
416
449
  this.#image = null;
417
450
  const ctx = this.#canvas.getContext("2d");
418
451
  if (ctx) {
419
- const dpr = globalThis.devicePixelRatio ?? 1;
420
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
452
+ const scale = this.#backingScale;
453
+ ctx.setTransform(scale.x, 0, 0, scale.y, 0, 0);
421
454
  ctx.clearRect(0, 0, this.#state.width, this.#state.height);
422
455
  }
423
456
 
@@ -447,8 +480,8 @@ export class MatplotlibRenderer {
447
480
  const ix = this.#interaction;
448
481
 
449
482
  // Scale for HiDPI: all coordinates remain in logical pixels
450
- const dpr = globalThis.devicePixelRatio ?? 1;
451
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
483
+ const scale = this.#backingScale;
484
+ ctx.setTransform(scale.x, 0, 0, scale.y, 0, 0);
452
485
 
453
486
  // Clear and draw the base image
454
487
  ctx.clearRect(0, 0, s.width, s.height);