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
19 changes: 19 additions & 0 deletions .changeset/react-doctor-studio-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@mdcms/studio": patch
---

Clean up `react-doctor` warnings in `@mdcms/studio`: score 66 → 84.

Internal-only changes; no behaviour changes to the published surface. Highlights:

- Workspace `tsconfig.base.json` bumped to ES2023 to unblock `Array.prototype.toSorted`.
- Mechanical sweeps: `toSorted` over `[...arr].sort()`, `flatMap` over `map().filter()`, ES2023 length-check shape, hoisted `EMPTY_BREADCRUMBS`, SVG `d` decimals truncated to two places, label `htmlFor`/id associations, redundant `role="navigation"` removed, `Promise.all` for independent awaits, `gap-y` over `space-y` on flex children, action-named button labels.
- React 19: `useContext(X)` migrated to `use(X)` across context modules.
- `proposal-card.tsx`: removed prop-mirroring `useState`/`useEffect` pattern; the reject panel now opens via local override `OR rejecting` prop, eliminating the stale first-render.
- `settings-page.tsx`: API-keys table renders dates through a `formatClientDate` helper and a client-mounted `ApiKeyStatusBadge` to avoid SSR/CSR locale + clock hydration mismatches. `api-key-create-dialog.tsx`: `min` date attr is now set after mount.
- Render-in-render extractions: `ContentCardGrid`/`ContentTypeCard`, `RetryButton`, `SchemaKindChip`/`SchemaConstraintFlags`, `ReadyMdxPropsEditor`/`AutoFormFieldControl`, `RouteContent`, `RowActions` (content/[type]), `TrashRowActions`.
- Login page: SSO state collapsed into a single discriminated-union state to remove the cascading two-`setState` effect.
- `studio-component.tsx` and `mdx-component-node-view.tsx`: documented the two intentional `dangerouslySetInnerHTML` sites inline.
- Added `packages/studio/knip.json` describing the two-tier runtime bundling so knip stops false-flagging `runtime-ui/**` as unused.

API rename inside the package: `renderReadyMdxPropsEditor` → `ReadyMdxPropsEditor` (the prior name was not exported from `src/index.ts`, so consumers outside the package are unaffected).
20 changes: 20 additions & 0 deletions packages/studio/knip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"$comment": "The runtime-ui tree, remote-studio-app, and remote-module are bundled at build-time by build-runtime.ts via dynamic string imports knip can't follow. Listing them as entry points keeps their transitive references visible to knip. See .changeset-gate.json for the same path list reflected on the publishing side.",
"entry": [
"src/index.ts",
"src/lib/build-runtime.ts",
"src/lib/document-shell.ts",
"src/lib/markdown-pipeline.ts",
"src/lib/action-catalog-adapter.ts",
"src/lib/studio.ts",
"src/lib/remote-module.ts",
"src/lib/remote-studio-app.tsx",
"src/lib/dev-runtime-watch.ts",
"src/lib/runtime-ui/**/*.{ts,tsx}",
"src/bun-test.d.ts",
"src/**/*.test.{ts,tsx}"
],
"project": ["src/**/*.{ts,tsx}"],
"ignoreExportsUsedInFile": true
}
26 changes: 14 additions & 12 deletions packages/studio/src/lib/build-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,20 @@ test("buildStudioRuntimeArtifacts is deterministic for identical source bytes",

await writeFile(sourceFile, "export const marker = 'stable';\n", "utf8");

const buildA = await buildStudioRuntimeArtifacts({
sourceFile,
outDir: outDirA,
studioVersion: "1.2.3",
mode: "module",
});
const buildB = await buildStudioRuntimeArtifacts({
sourceFile,
outDir: outDirB,
studioVersion: "1.2.3",
mode: "module",
});
const [buildA, buildB] = await Promise.all([
buildStudioRuntimeArtifacts({
sourceFile,
outDir: outDirA,
studioVersion: "1.2.3",
mode: "module",
}),
buildStudioRuntimeArtifacts({
sourceFile,
outDir: outDirB,
studioVersion: "1.2.3",
mode: "module",
}),
]);

assert.equal(buildA.buildId, buildB.buildId);
assert.equal(buildA.entryFile, buildB.entryFile);
Expand Down
11 changes: 4 additions & 7 deletions packages/studio/src/lib/build-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,10 @@ export async function buildStudioRuntimeArtifacts(
const studioVersion =
options.studioVersion ?? (process.env.APP_VERSION?.trim() || "0.0.0");

const bundledEntry = await bundleRuntimeEntry({
projectRoot,
sourceFile,
});
const stylesheetResult = await compileRuntimeStylesheet({
projectRoot,
});
const [bundledEntry, stylesheetResult] = await Promise.all([
bundleRuntimeEntry({ projectRoot, sourceFile }),
compileRuntimeStylesheet({ projectRoot }),
]);

const entryBytes = new TextEncoder().encode(bundledEntry);
const integritySha256 = sha256Hex(entryBytes);
Expand Down
4 changes: 1 addition & 3 deletions packages/studio/src/lib/content-overview-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,7 @@ function createErrorState(input: {
}

function sortEntries(entries: StudioContentOverviewEntry[]) {
return [...entries].sort((left, right) =>
left.type.localeCompare(right.type),
);
return entries.toSorted((left, right) => left.type.localeCompare(right.type));
}

async function loadEntryMetrics(input: {
Expand Down
95 changes: 47 additions & 48 deletions packages/studio/src/lib/document-route-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,60 +189,59 @@ test("prepared document route metadata includes per-environment hashes and field
});

test("equivalent authored config data yields the same schema hash", async () => {
const left = await resolveStudioDocumentRouteSchemaCapability(
defineConfig({
project: "marketing-site",
serverUrl: "http://localhost:4000",
environment: "staging",
contentDirectories: ["content"],
locales: {
default: "en",
supported: ["en", "fr"],
aliases: {
"en-us": "en",
"fr-ca": "fr",
},
},
types: [
defineType("Article", {
directory: "content/articles",
fields: {
title: reference("Article"),
body: reference("Article"),
const [left, right] = await Promise.all([
resolveStudioDocumentRouteSchemaCapability(
defineConfig({
project: "marketing-site",
serverUrl: "http://localhost:4000",
environment: "staging",
contentDirectories: ["content"],
locales: {
default: "en",
supported: ["en", "fr"],
aliases: {
"en-us": "en",
"fr-ca": "fr",
},
}),
defineType("Author", {
directory: "content/authors",
fields: {
bio: reference("Author"),
name: reference("Author"),
},
}),
],
environments: {
staging: {
extends: "production",
types: {
Author: {
modify: {
bio: reference("Author"),
},
},
types: [
defineType("Article", {
directory: "content/articles",
fields: {
title: reference("Article"),
body: reference("Article"),
},
Article: {
add: {
summary: reference("Article"),
}),
defineType("Author", {
directory: "content/authors",
fields: {
bio: reference("Author"),
name: reference("Author"),
},
}),
],
environments: {
staging: {
extends: "production",
types: {
Author: {
modify: {
bio: reference("Author"),
},
},
Article: {
add: {
summary: reference("Article"),
},
},
},
},
production: {},
},
production: {},
},
}),
);

const right = await resolveStudioDocumentRouteSchemaCapability(
createAuthoredConfig(),
);
}),
),
resolveStudioDocumentRouteSchemaCapability(createAuthoredConfig()),
]);

assert.equal(left.canWrite, true);
assert.equal(right.canWrite, true);
Expand Down
8 changes: 4 additions & 4 deletions packages/studio/src/lib/document-route-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function resolveEnvironmentFieldTargets(
const result = Object.fromEntries(
Array.from(fieldTargets.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([typeName, typeFieldTargets]) => {
.flatMap(([typeName, typeFieldTargets]) => {
const scopedFields = Object.fromEntries(
Array.from(typeFieldTargets.entries())
.sort(([left], [right]) => left.localeCompare(right))
Expand All @@ -148,9 +148,9 @@ function resolveEnvironmentFieldTargets(
}),
);

return [typeName, scopedFields] as const;
})
.filter(([, scopedFields]) => Object.keys(scopedFields).length > 0),
if (Object.keys(scopedFields).length === 0) return [];
return [[typeName, scopedFields] as const];
}),
);

return result;
Expand Down
9 changes: 4 additions & 5 deletions packages/studio/src/lib/document-version-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,10 @@ function isEqualValue(left: unknown, right: unknown): boolean {
}

if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length) {
return false;
}

return left.every((entry, index) => isEqualValue(entry, right[index]));
return (
left.length === right.length &&
left.every((entry, index) => isEqualValue(entry, right[index]))
);
}

if (isRecord(left) && isRecord(right)) {
Expand Down
5 changes: 3 additions & 2 deletions packages/studio/src/lib/mdx-component-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,9 @@ export function serializeMdxJsxAttributes(
input: Record<string, unknown>,
): string {
return Object.entries(input)
.filter(([, value]) => value !== undefined)
.map(([name, value]) => `${name}=${formatAttributeValue(value)}`)
.flatMap(([name, value]) =>
value === undefined ? [] : [`${name}=${formatAttributeValue(value)}`],
)
.join(" ");
}

Expand Down
6 changes: 3 additions & 3 deletions packages/studio/src/lib/mdx-props-editor-host.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
createInitialMdxPropsEditorHostState,
createMdxPropsEditorBindings,
MdxPropsEditorHost,
renderReadyMdxPropsEditor,
ReadyMdxPropsEditor,
resolveMdxPropsEditorHostState,
type PropsEditorComponentProps,
} from "./mdx-props-editor-host.js";
Expand Down Expand Up @@ -305,12 +305,12 @@ test("MdxPropsEditorHost renders compact type hints for generated auto-form fiel
assert.match(markup, />color</);
});

test("renderReadyMdxPropsEditor keeps diagnostics out of the visible custom editor surface", () => {
test("ReadyMdxPropsEditor keeps diagnostics out of the visible custom editor surface", () => {
const markup = renderToStaticMarkup(
createElement(
"section",
null,
renderReadyMdxPropsEditor({
createElement(ReadyMdxPropsEditor, {
componentName: "PricingTable",
editor: (_props: PropsEditorComponentProps) =>
createElement(
Expand Down
44 changes: 22 additions & 22 deletions packages/studio/src/lib/mdx-props-editor-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,11 @@ export function MdxPropsEditorHost({

return (
<PropsEditorRenderBoundary componentName={component.name}>
{renderReadyMdxPropsEditor({
componentName: component.name,
editor: state.editor as PropsEditorComponent<PropsEditorValue>,
bindings,
})}
<ReadyMdxPropsEditor
componentName={component.name}
editor={state.editor as PropsEditorComponent<PropsEditorValue>}
bindings={bindings}
/>
</PropsEditorRenderBoundary>
);
}
Expand Down Expand Up @@ -313,7 +313,7 @@ function createFallbackState(
: { status: "empty" };
}

export function renderReadyMdxPropsEditor(input: {
export function ReadyMdxPropsEditor(input: {
componentName: string;
editor: PropsEditorComponent<PropsEditorValue>;
bindings: PropsEditorComponentProps<PropsEditorValue>;
Expand Down Expand Up @@ -369,20 +369,20 @@ function renderAutoFormFields(
{formatAutoFormFieldTypeHint(field)}
</span>
</label>
{renderAutoFormFieldControl({
componentName,
field,
value: value[field.name],
onChange,
readOnly,
})}
<AutoFormFieldControl
componentName={componentName}
field={field}
value={value[field.name]}
onChange={onChange}
readOnly={readOnly}
/>
</div>
))}
</div>
);
}

function renderAutoFormFieldControl(input: {
function AutoFormFieldControl(input: {
componentName: string;
field: MdxAutoFormField;
value: unknown;
Expand Down Expand Up @@ -703,19 +703,19 @@ function formatAutoFormListValue(value: unknown): string {
}

function parseAutoFormStringListValue(value: string): string[] | undefined {
const items = value
.split("\n")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const items = value.split("\n").flatMap((entry) => {
const trimmed = entry.trim();
return trimmed.length > 0 ? [trimmed] : [];
});

return items.length > 0 ? items : undefined;
}

function parseAutoFormNumberListValue(value: string): number[] | undefined {
const items = value
.split("\n")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const items = value.split("\n").flatMap((entry) => {
const trimmed = entry.trim();
return trimmed.length > 0 ? [trimmed] : [];
});

if (items.length === 0) {
return undefined;
Expand Down
Loading
Loading