@marimo-team/islands 0.23.3-dev45 → 0.23.3-dev48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.3-dev45",
3
+ "version": "0.23.3-dev48",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -18,6 +18,7 @@ import { memo, useState } from "react";
18
18
  import type { OutputMessage } from "@/core/kernel/messages";
19
19
  import { cn } from "@/utils/cn";
20
20
  import { copyToClipboard } from "@/utils/copy";
21
+ import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
21
22
  import { isUrl } from "@/utils/urls";
22
23
  import { useTheme } from "../../../theme/useTheme";
23
24
  import { logNever } from "../../../utils/assertNever";
@@ -386,14 +387,41 @@ function renderLeaf(leaf: string, render: LeafRenderer): React.ReactNode {
386
387
  // See `_key_formatter` in marimo/_output/formatters/structures.py.
387
388
  const KEY_ENCODED_PREFIX = "text/plain+";
388
389
 
390
+ // Format elements for a Python collection literal. Non-finite floats
391
+ // (NaN / Infinity / -Infinity) parse as JS `number` via
392
+ // `jsonParseWithSpecialChar`; `JSON.stringify` on those returns `null`,
393
+ // so render them as the same `float(...)` literals we use for scalar
394
+ // float keys (see `decodeKeyForCopy`).
395
+ function formatCollectionItems(items: unknown[]): string {
396
+ return items
397
+ .map((x) => {
398
+ if (typeof x === "number" && !Number.isFinite(x)) {
399
+ if (Number.isNaN(x)) {
400
+ return "float('nan')";
401
+ }
402
+ return x > 0 ? "float('inf')" : "-float('inf')";
403
+ }
404
+ return JSON.stringify(x);
405
+ })
406
+ .join(", ");
407
+ }
408
+
389
409
  // Format a JSON-list payload as a Python tuple literal. 1-element tuples
390
410
  // need a trailing comma — `(1)` is just `1` in Python, `(1,)` is the tuple.
411
+ // Uses `jsonParseWithSpecialChar` so bare `NaN`/`Infinity`/`-Infinity`
412
+ // emitted by Python's json.dumps round-trip cleanly.
391
413
  function formatTuplePayload(jsonList: string): string {
392
- const items = JSON.parse(jsonList) as unknown[];
393
- const inner = items.map((x) => JSON.stringify(x)).join(", ");
414
+ const items = jsonParseWithSpecialChar<unknown[]>(jsonList);
415
+ // `jsonParseWithSpecialChar` returns `{}` when both parse passes fail;
416
+ // fall back to the raw payload so a malformed wire form doesn't crash
417
+ // rendering/copy. Matches the defensive pattern in `formatSetPayload`.
418
+ if (!Array.isArray(items)) {
419
+ return jsonList;
420
+ }
394
421
  if (items.length === 0) {
395
422
  return "()";
396
423
  }
424
+ const inner = formatCollectionItems(items);
397
425
  if (items.length === 1) {
398
426
  return `(${inner},)`;
399
427
  }
@@ -403,29 +431,31 @@ function formatTuplePayload(jsonList: string): string {
403
431
  // Format a JSON-list payload as a Python frozenset literal. Empty → `frozenset()`
404
432
  // rather than `frozenset({})` (which reads like a dict).
405
433
  function formatFrozensetPayload(jsonList: string): string {
406
- const items = JSON.parse(jsonList) as unknown[];
434
+ const items = jsonParseWithSpecialChar<unknown[]>(jsonList);
435
+ if (!Array.isArray(items)) {
436
+ return jsonList;
437
+ }
407
438
  if (items.length === 0) {
408
439
  return "frozenset()";
409
440
  }
410
- const inner = items.map((x) => JSON.stringify(x)).join(", ");
441
+ const inner = formatCollectionItems(items);
411
442
  return `frozenset({${inner}})`;
412
443
  }
413
444
 
414
445
  // Format a JSON-list payload as a Python set literal. Empty → `set()`
415
446
  // (not `{}`, which is a dict literal in Python).
416
447
  function formatSetPayload(jsonList: string): string {
417
- try {
418
- const items = JSON.parse(jsonList) as unknown[];
419
- if (items.length === 0) {
420
- return "set()";
421
- }
422
- const inner = items.map((x) => JSON.stringify(x)).join(", ");
423
- return `{${inner}}`;
424
- } catch {
448
+ const items = jsonParseWithSpecialChar<unknown[]>(jsonList);
449
+ if (!Array.isArray(items)) {
425
450
  // Back-compat: older wire form was `text/plain+set:{1, 2, 3}` (Python
426
451
  // set-literal string, not JSON). Pass it through as-is rather than crash.
427
452
  return jsonList;
428
453
  }
454
+ if (items.length === 0) {
455
+ return "set()";
456
+ }
457
+ const inner = formatCollectionItems(items);
458
+ return `{${inner}}`;
429
459
  }
430
460
 
431
461
  // Renderers for decoded non-string keys. Visual affordances match Python:
@@ -417,6 +417,44 @@ describe("getCopyValue with encoded non-string keys", () => {
417
417
  `);
418
418
  });
419
419
 
420
+ it("parses tuple/frozenset payloads containing bare NaN/Infinity", () => {
421
+ // Python's json.dumps emits bare `NaN`/`Infinity` inside the embedded
422
+ // tuple/frozenset payload strings (JSON spec violation, but ECMA-262-
423
+ // friendly via the fallback in jsonParseWithSpecialChar). The outer
424
+ // JSON stays strict because those tokens live inside a JSON string
425
+ // key/value. Regression for tuple-key payloads that previously broke
426
+ // the frontend's `JSON.parse` and threw.
427
+ const value = {
428
+ "text/plain+tuple:[NaN]": "tn",
429
+ "text/plain+tuple:[Infinity, -Infinity]": "ti",
430
+ k: "text/plain+frozenset:[Infinity, 1]",
431
+ };
432
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
433
+ "{
434
+ (float('nan'),): "tn",
435
+ (float('inf'), -float('inf')): "ti",
436
+ "k": frozenset({float('inf'), 1})
437
+ }"
438
+ `);
439
+ });
440
+
441
+ it("falls back to the raw payload for malformed tuple/frozenset", () => {
442
+ // `jsonParseWithSpecialChar` returns `{}` on parse failure rather
443
+ // than throwing; without an `Array.isArray` guard, the formatters
444
+ // would crash on `.length`/`.map`. Pass the raw payload through so
445
+ // a malformed wire form doesn't break the whole render.
446
+ const value = {
447
+ "text/plain+tuple:not a json list": "t",
448
+ k: "text/plain+frozenset:also broken",
449
+ };
450
+ expect(getCopyValue(value)).toMatchInlineSnapshot(`
451
+ "{
452
+ not a json list: "t",
453
+ "k": also broken
454
+ }"
455
+ `);
456
+ });
457
+
420
458
  it("unescapes string keys that looked encoded", () => {
421
459
  const value = {
422
460
  "text/plain+str:text/plain+int:2": "hello",