From 35fc449f5d65a65907afb34cafd14a7ea67a9e51 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 4 Aug 2026 14:17:29 -0400 Subject: [PATCH] fix(editor): install a structuredClone fallback where lingo is imported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@pascal-app/lingo` builds its unit registry at module-eval time and `registerKind` deep-copies each kind definition with `structuredClone`, so a browser without that global (Chromium <98, reported from Honor Browser 9.8 as `ReferenceError: structuredClone is not defined`) fails while the module graph is still evaluating. Sentry MONOREPO-EDITOR-FB. The shim belongs next to the import that makes lingo load-reachable, not in an app entry: `packages/editor` is what the OSS app, the hosted app and every npm consumer all load. An app-level polyfill covers only the app that declares it. Scoped narrowly and documented as such — lingo's kind table is plain JSON, so a JSON round-trip suffices and needs no new dependency. It is not spec-compliant and must not be relied on for real structured-clone semantics. --- packages/editor/src/lib/measurement-parser.ts | 3 ++ .../src/lib/structured-clone-fallback.test.ts | 45 +++++++++++++++++++ .../src/lib/structured-clone-fallback.ts | 24 ++++++++++ 3 files changed, 72 insertions(+) create mode 100644 packages/editor/src/lib/structured-clone-fallback.test.ts create mode 100644 packages/editor/src/lib/structured-clone-fallback.ts diff --git a/packages/editor/src/lib/measurement-parser.ts b/packages/editor/src/lib/measurement-parser.ts index d42ae112f..2cafe7387 100644 --- a/packages/editor/src/lib/measurement-parser.ts +++ b/packages/editor/src/lib/measurement-parser.ts @@ -1,3 +1,6 @@ +// Must precede the lingo import: lingo clones its kind table with +// structuredClone while its module body evaluates. See the file's own comment. +import './structured-clone-fallback' import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo' /** diff --git a/packages/editor/src/lib/structured-clone-fallback.test.ts b/packages/editor/src/lib/structured-clone-fallback.test.ts new file mode 100644 index 000000000..d3e9effda --- /dev/null +++ b/packages/editor/src/lib/structured-clone-fallback.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +const native = globalThis.structuredClone + +afterEach(() => { + globalThis.structuredClone = native +}) + +async function installFallback() { + // Fresh module instance each time — the shim is a module side effect. + await import(`./structured-clone-fallback?${Math.random()}`) +} + +describe('structuredClone fallback', () => { + test('leaves a native implementation alone', async () => { + await installFallback() + expect(globalThis.structuredClone).toBe(native) + }) + + test('installs a JSON-based clone when the global is missing', async () => { + // @ts-expect-error — simulating a browser without the global. + globalThis.structuredClone = undefined + await installFallback() + + const source = { units: [{ factor: 1, id: 'm' }], kind: 'length' } + const copy = structuredClone(source) + + expect(copy).toEqual(source) + expect(copy).not.toBe(source) + expect(copy.units).not.toBe(source.units) + }) + + test('lets lingo build its registry without the global', async () => { + // @ts-expect-error — simulating a browser without the global. + globalThis.structuredClone = undefined + await installFallback() + + // The actual regression: lingo clones its kind table at module-eval time, + // so this import throws on a browser lacking structuredClone. + const { parseQuantity } = await import('@pascal-app/lingo') + const result = parseQuantity('180cm', { kind: 'length', unit: 'm' }) + expect(result.ok).toBe(true) + if (result.ok) expect(result.quantity.to('m').value).toBeCloseTo(1.8, 6) + }) +}) diff --git a/packages/editor/src/lib/structured-clone-fallback.ts b/packages/editor/src/lib/structured-clone-fallback.ts new file mode 100644 index 000000000..8ce1c1dc0 --- /dev/null +++ b/packages/editor/src/lib/structured-clone-fallback.ts @@ -0,0 +1,24 @@ +/** + * `@pascal-app/lingo` builds its unit registry at module-eval time, and + * `registerKind` deep-copies each kind definition with `structuredClone`. On a + * browser without that global (Chromium <98 — reported from Honor Browser 9.8 + * as `ReferenceError: structuredClone is not defined`) the throw happens while + * the module graph is still evaluating, so a component-level guard can never + * run in time and the whole editor bundle fails to load. + * + * This has to be imported for its side effect from the module that pulls lingo + * in, so it is installed wherever `@pascal-app/editor` is loaded — the OSS app, + * the hosted app, and any npm consumer. An app-level polyfill would only cover + * the app that declares it. + * + * Scoped deliberately narrowly: the kind definitions lingo clones are plain + * JSON (strings, numbers, arrays of those), so a JSON round-trip is sufficient + * and avoids a dependency. This is NOT a spec-compliant `structuredClone` — it + * has no support for Map/Set/Date/ArrayBuffer/cycles and throws a `TypeError` + * rather than a `DataCloneError`. Anything needing real structured-clone + * semantics must not rely on this shim. + */ +if (typeof globalThis.structuredClone !== 'function') { + globalThis.structuredClone = ((value: T): T => + JSON.parse(JSON.stringify(value)) as T) as typeof structuredClone +}