Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/app-shell-nodenext-pin-5440.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@object-ui/app-shell': patch
---

Pin `module` / `moduleResolution` to `nodenext` in `@object-ui/app-shell`'s build config, matching the pins `@object-ui/react`, `@object-ui/fields` and five other packages already carry. This package builds with a bare `tsc`, which never rewrites import specifiers, so what the source writes is exactly what `dist` ships; under `nodenext` a missing relative extension is a compile error, so the extensionless-specifier defect that made published entries unloadable under plain Node cannot come back silently here.

The lazy `@monaco-editor/react` imports in the metadata designer's source editors now read the package's named `Editor` export instead of its default. `@monaco-editor/react@4.7.0` is CommonJS and ships no `exports` map, so under `nodenext` the default resolves to the module namespace rather than to the component. The two names are one declaration in that package's own typings and the same object at runtime in both its CommonJS and ESM builds, so the editor that renders is unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import { render, screen } from '@testing-library/react';

// Monaco "loads" (loader.init resolves) but renders nothing, so the DOM-poll
// backstop — not the loader fast-fail path — is what must engage here.
vi.mock('@monaco-editor/react', () => ({
default: () => null,
loader: { init: () => Promise.resolve({}) },
}));
//
// `Editor` and `default` are ONE declaration in the real package, and the
// component under test imports the NAMED one (objectui#5440), so the stub is
// bound to both names rather than to `default` alone.
vi.mock('@monaco-editor/react', () => {
const Editor = () => null;
return { Editor, default: Editor, loader: { init: () => Promise.resolve({}) } };
});

import { JsonSourceEditor } from './JsonSourceEditor';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The lazy Monaco import must resolve to the EDITOR component (objectui#5440).
*
* `packages/app-shell/tsconfig.json` pins `moduleResolution: nodenext`, under
* which `@monaco-editor/react`'s CommonJS default is the module namespace
* rather than the component, so the lazy import reads the named `Editor`
* export instead.
*
* The compiler covers more of that spelling than one might assume, and this
* was measured rather than guessed: reverting the factory to the namespace
* default is TS2345, and pointing it at the sibling `DiffEditor` is TS2353 on
* the first option this call site passes that a diff editor does not take
* (`tabSize`). So the wrong-component-but-typed case does NOT survive
* `type-check` here, and this test is not what catches it.
*
* What nothing else covers is the editor resolving to NOTHING. Both Monaco
* suites next door assert the textarea FALLBACK, and a lazy import that never
* yields a component produces exactly that fallback — measured: remove
* `Editor` from `JsonSourceEditor.fallback.test.tsx`'s stub, leaving the lazy
* factory reading an export that is not there, and that suite still passes,
* because the DOM-poll backstop flips to the textarea before the broken import
* is ever rendered. Green there means "the fallback works", never "the editor
* works". This is the test that renders the editor and asserts it painted.
*
* The stub mirrors the real module — `Editor` and `DiffEditor` distinguishable,
* and `default` bound to the namespace-shaped object `nodenext` hands the code.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';

vi.mock('@monaco-editor/react', () => {
const Editor = () => <div className="view-line" data-testid="monaco-editor" />;
const DiffEditor = () => <div className="view-line" data-testid="monaco-diff-editor" />;
return {
Editor,
DiffEditor,
default: { Editor, DiffEditor },
loader: { init: () => Promise.resolve({}) },
};
});

import { JsonSourceEditor } from './JsonSourceEditor';

describe('JsonSourceEditor — lazy Monaco export shape', () => {
it('renders the named `Editor` export, not `DiffEditor` and not the namespace default', async () => {
// A long grace period so the textarea fallback cannot engage on a timer:
// if it appears at all, it is because the editor never painted.
render(
<JsonSourceEditor
value={{ name: 'work_order' }}
onChange={() => {}}
fallbackDelayMs={60_000}
/>,
);

expect(await screen.findByTestId('monaco-editor', {}, { timeout: 2000 })).toBeInTheDocument();
expect(screen.queryByTestId('monaco-diff-editor')).toBeNull();
expect(screen.queryByLabelText('JSON source')).toBeNull();
});
});
15 changes: 14 additions & 1 deletion packages/app-shell/src/views/metadata-admin/JsonSourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,22 @@ import { useMonacoFallback } from './useMonacoFallback.js';

// Lazy: Monaco's React wrapper itself pulls in the editor core
// (~3MB), so we keep it out of the initial app-shell chunk.
//
// The NAMED `Editor` export, not `default`: `@monaco-editor/react@4.7.0` is
// CommonJS and ships no `exports` map, so under this package's `nodenext`
// resolution the default is the module NAMESPACE rather than the component,
// and `React.lazy` rejects it (TS2345 — objectui#5440).
//
// It is the SAME component, not a near-enough substitute — checked in the
// installed 4.7.0 rather than assumed from the name. Its own typings alias one
// declaration to both spellings (`export { _default as Editor, ...,
// _default as default }`), and the two are identical at runtime in its CJS
// build and its ESM build alike. That is what makes this safe under `bundler`
// resolution too, which is what the console actually bundles with and where
// the previous spelling worked.
const LazyMonaco = React.lazy(async () => {
const mod = await import('@monaco-editor/react');
return { default: mod.default };
return { default: mod.Editor };
});

export interface JsonIssue {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,16 @@ import { SchemaRenderer } from '@object-ui/react';
import { PreviewShell, PreviewErrorBoundary } from './PreviewShell.js';
import { useMonacoFallback } from '../useMonacoFallback.js';

// Lazy for the same reason as JsonSourceEditor — Monaco's core is ~3MB and
// stays out of the initial app-shell chunk.
//
// The NAMED `Editor` export for the same reason too: under `nodenext` this
// CommonJS package's default is the module namespace, not the component
// (objectui#5440). `../JsonSourceEditor.tsx` carries the interop reading and
// the evidence that both spellings are one declaration.
const LazyMonaco = React.lazy(async () => {
const mod = await import('@monaco-editor/react');
return { default: mod.default };
return { default: mod.Editor };
});

export interface SourcePageEditorProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';

vi.mock('@monaco-editor/react', () => ({
default: () => null,
// Simulate the CDN loader script failing to load.
loader: { init: () => Promise.reject(new Error('CDN blocked')) },
}));
// `Editor` and `default` are ONE declaration in the real package, and the
// component under test imports the NAMED one (objectui#5440), so the stub is
// bound to both names rather than to `default` alone.
vi.mock('@monaco-editor/react', () => {
const Editor = () => null;
return {
Editor,
default: Editor,
// Simulate the CDN loader script failing to load.
loader: { init: () => Promise.reject(new Error('CDN blocked')) },
};
});

import { JsonSourceEditor } from './JsonSourceEditor';

Expand Down
21 changes: 20 additions & 1 deletion packages/app-shell/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,26 @@
"types": ["node", "vite/client"],
"noEmit": false,
"declaration": true,
"composite": true
"composite": true,

// See `packages/react/tsconfig.json` for the full argument: under
// `nodenext` a missing relative extension is TS2835 and a bare directory
// import is TS2834, so the extension property this package's `dist` needs
// is enforced by the compiler instead of by review. It is load-bearing
// HERE in particular: this package builds with a bare `tsc`
// (`"build": "tsc"`), and `tsc` never rewrites specifiers, so what the
// source writes is exactly what `dist` ships — objectui#4538's failure.
//
// Landed last of the consumer pins because it was the largest, and each
// number below was a different card's work, measured with this pin used as
// an instrument before it was used as enforcement (objectui#5440):
// 1097 errors before objectui#5365 taught `@object-ui/components` to emit
// resolvable typings, 23 after it, 2 after objectui#5439 cleared the
// extensionless typings `@object-ui/plugin-chatbot` re-exported through.
// Re-measured on this branch at f8c70f4f3: unpinned 0, pinned 2, both of
// them the `@monaco-editor/react` interop site fixed in this same commit.
"module": "nodenext",
"moduleResolution": "nodenext"
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"],
Expand Down
Loading