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
30 changes: 30 additions & 0 deletions .changeset/shell-measures-its-container.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/builder": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/module-specifiers": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/ui": patch
"create-nextly-app": patch
"nextly": patch
---

The page editor now decides whether it fits by measuring the space it was actually given, rather than the size of the browser window. Embedded as a field inside a form on a wide screen, it used to conclude it had room and squeeze the rail, panel and canvas below the widths they need; it now shows the "needs a wider screen" message in that case, and goes back to the full layout as soon as the space grows again.

The media picker also no longer floats over that message. It opens in a layer outside the editor, so hiding the editor left an open picker visible and clickable on top of the notice saying the editor was unavailable.
22 changes: 20 additions & 2 deletions apps/playground/src/app/builder-shell/harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,27 @@ import "@nextlyhq/builder/styles.css";
* Slot contents are deliberately inert. Anything interactive here would be
* testing the harness rather than the shell.
*/
export function BuilderShellHarness() {
export function BuilderShellHarness({
containerWidth,
}: {
/**
* Constrain the shell's container without touching the window.
*
* Omitted, the shell fills the viewport as before. Supplied, it gets a box
* narrower than the window — the arrangement that separates measuring the
* container from measuring the viewport, which no viewport-sized harness
* can distinguish.
*/
containerWidth?: number;
}) {
return (
<div style={{ height: "100vh" }}>
<div
data-testid="shell-container"
style={{
height: "100vh",
width: containerWidth === undefined ? undefined : containerWidth,
}}
>
<BuilderShell
onExit={() => {
// Recorded in the DOM rather than navigating: the assertion is that
Expand Down
27 changes: 25 additions & 2 deletions apps/playground/src/app/builder-shell/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,35 @@ import { BuilderShellHarness } from "./harness";
* The slot contents are intentionally inert. Anything interactive here would be
* testing the harness.
*/
export default function BuilderShellHarnessRoute() {
export default async function BuilderShellHarnessRoute({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
// GATED, for the reason the style-fixture plugin had to be: a dev-only route
// under `src/app/` is always reachable in `pnpm dev:app` and indistinguishable
// from product. That exact shape put a test-double plugin into a
// contributor's real plugins list. Same env-var gate as
// `NEXTLY_E2E_STYLE_FIXTURE`, so the two are recognisable as one convention.
if (process.env.NEXTLY_E2E_SHELL_HARNESS !== "1") notFound();
return <BuilderShellHarness />;

/*
* An optional CONTAINER width, so a test can give the shell a narrow box
* inside a wide window.
*
* That combination is the one the shell used to get wrong and the one no
* viewport-sized harness can express: it sizes to its container, so a wide
* window around a narrow column is precisely where measuring the window
* reports "fits" while the layout is being compressed past its minimums.
*
* Read on the SERVER and passed down, so the first client render already has
* it — resolving it in the browser instead would render full-width once and
* narrow afterwards, which is a hydration mismatch and also hides the very
* first measurement the test is about.
*/
const raw = (await searchParams).container;
const parsed = Number(typeof raw === "string" ? raw : "");
const container = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;

return <BuilderShellHarness containerWidth={container} />;
}
69 changes: 69 additions & 0 deletions e2e/tests/shell/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,75 @@ test.describe("a viewport below the supported width", () => {
});
});

test.describe("a narrow CONTAINER inside a wide window", () => {
// The window is comfortably above the threshold throughout this block. Every
// assertion below is about the shell's own box, which is the quantity it
// sizes to (`h-full w-full`, no viewport units anywhere).
test.use({ viewport: { width: 1600, height: 900 } });

test("shows the notice when its column is too narrow to hold the layout", async ({
page,
}) => {
// The defect this replaced: `matchMedia` reported the WINDOW, so an editor
// embedded in a narrow admin column on a wide screen was told it fitted and
// compressed the rail, panel and canvas past their minimums. The window
// here is 1600 and the container is below the threshold, so a viewport
// measurement and a container measurement give opposite answers — which is
// what makes this the separating case rather than a second narrow test.
await page.goto(`/builder-shell?container=${MIN_SHELL_WIDTH - 100}`);

await expect(page.getByText(/needs a wider screen/i)).toBeVisible();
await expect(page.getByRole("region", { name: "Canvas" })).toHaveCount(0);
});

test("renders the full layout when its column is wide enough", async ({
page,
}) => {
// The positive control. Without it, a shell that showed the notice
// unconditionally — or one whose measurement never resolved — would satisfy
// the assertion above perfectly.
await page.goto(`/builder-shell?container=${MIN_SHELL_WIDTH + 200}`);

await expect(page.getByRole("region", { name: "Canvas" })).toBeVisible();
await expect(page.getByText(/needs a wider screen/i)).toHaveCount(0);
});

test("recovers when the container grows back", async ({ page }) => {
// THE DEADLOCK, and the reason the observer follows the VISIBLE root.
//
// The editor stays mounted behind the notice so unsaved slot state
// survives, and its wrapper is `display: contents` when visible and
// `hidden` when narrow. Observing THAT element reports width 0 while the
// notice is up, so `0 >= MIN_SHELL_WIDTH` stays false and the shell can
// never measure its way back — narrowing once would disable the editor for
// the rest of the session.
//
// This has to run in a browser. The unit suite's ResizeObserver is a fake
// that reports the width the test sets regardless of which element is
// observed, so it answers identically for the hidden wrapper and the
// visible root — verified by writing the deadlock deliberately, which left
// the unit file green.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await page.goto(`/builder-shell?container=${MIN_SHELL_WIDTH - 100}`);
await expect(page.getByText(/needs a wider screen/i)).toBeVisible();

// Grow the CONTAINER, not the window: the window never changed.
//
// Grown to JUST above the threshold rather than comfortably past it. The
// narrow notice carries padding the shell root does not, so a measurement
// read from the CONTENT box reports this container narrower than it is
// while the notice is up, and recovery into that band never happens. A
// target of `+200` clears the threshold with or without the padding
// subtracted, so it cannot tell the two implementations apart — which is
// exactly what an earlier version of this test failed to do.
await page.getByTestId("shell-container").evaluate((node, width) => {
node.style.width = `${width}px`;
}, MIN_SHELL_WIDTH + 20);

await expect(page.getByRole("region", { name: "Canvas" })).toBeVisible();
await expect(page.getByText(/needs a wider screen/i)).toHaveCount(0);
});
});

test.describe("the readiness diagnostic itself", () => {
test.use({ viewport: { width: 1440, height: 900 } });

Expand Down
Loading
Loading