Add this issue to the directus visual-editor repo but might prove useful for anyone struggling with editible regions showing up in the visual editor outside of the preview frame.
opened 12:27PM - 19 Feb 26 UTC
### Describe the Feature
I've been building a site with Next.js 16 (App Router)… using ISR and hit a problem with the visual editor that had me pulling my hair out for a bit.
Everything works fine in the preview pane next to the edit form in the Directus interface. But when I use the standalone visual editor module (`/admin/visual/[url]`), no editable regions show up when clicking the pencil icon. Sometimes a hard refresh sorts it out, sometimes it don't.
## The problem
After digging through the source (with a good deal of help from AI 😄), discovered the issue. My pages are server-rendered with ISR so they're fast. The iframe hydrates in well under 100ms. When `apply()` runs, it fires off a single `connect` postMessage to the parent and then `receiveConfirm()` checks 10 times at 100ms intervals (so 1 second total) waiting for a `confirm` back. If nothing comes back in time, `apply()` just quietly returns `undefined`. No error or console warning that I could see. `State.items` stays empty and the pencil toggle has nothing to work with.
In the preview pane this is fine because Directus is already up and running by the time the iframe loads. But in the standalone visual editor module, Directus is spinning up the module and loading the iframe at the same time. My page wins that race. It hydrates and fires `apply()` before the module's `EditingLayer` component has even mounted its message listener.
I think the official Next.js tutorial gets away with this because it tells you to convert your page to a client component with `useEffect` data fetching. All those API calls slow things down enough that Directus is already listening by the time `apply()` fires. If you're using SSR/ISR/SSG though, your pages hydrate way too fast and you'll hit this every time.
## The fix
My first attempt (AI's suggestion, not mine 😅) was to just retry `apply()` on a delay if it failed. That was overkill: every retry was doing a full DOM scan, creating overlay elements, setting up observers etc.
Enter a `waitForDirectus()` function that runs before `apply()`. It pings the parent frame with `{ action: "connect" }` every 500ms and listens for a `{ action: "confirm" }` back. As soon as Directus responds, it stops and hands off to `apply()`. Works first time because Directus is ready.
```typescript
function waitForDirectus(): Promise<void> {
return new Promise((resolve) => {
const origin = new URL(directusUrl).origin;
function onMessage(event: MessageEvent) {
if (event.origin !== origin) return;
if (event.data?.action === "confirm") {
clearInterval(probeInterval);
window.removeEventListener("message", onMessage);
resolve();
}
}
window.addEventListener("message", onMessage);
window.parent.postMessage({ action: "connect" }, directusUrl);
const probeInterval = setInterval(() => {
window.parent.postMessage({ action: "connect" }, directusUrl);
}, 500);
});
}
```
In the preview pane this resolves straight away since Directus is already there. In the standalone module it just waits until Directus is actually ready.
Second issue. Navigating between pages inside the iframe (clicking links on the site), the pencil stopped working again. Turns out Next.js does a client-side soft navigation so the wrapper component stays mounted but the DOM is completely different. The library's `State.items` is still pointing at elements from the old page. Fixed that by adding `usePathname()` from `next/navigation` as a dependency on the `useEffect` that initialises the visual editor. When the path changes, it cleans up and re-initialises, so `apply()` re-scans the new page's elements.
Wrapper I ended up with:
```tsx
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { initializeVisualEditor, cleanupVisualEditor } from "@/lib/visual-editor";
export function VisualEditorWrapper({ children }) {
const pathname = usePathname();
useEffect(() => {
if (document.readyState === "complete") {
initializeVisualEditor();
} else {
window.addEventListener("load", initializeVisualEditor);
return () => {
window.removeEventListener("load", initializeVisualEditor);
cleanupVisualEditor();
};
}
return () => {
cleanupVisualEditor();
};
}, [pathname]);
return <>{children}</>;
}
```
`waitForDirectus()` lives inside `initializeVisualEditor()` in a separate `visual-editor.ts` file.
Oh and because I'm using ISR, I also have a custom `onSaved` that hits a revalidation endpoint and adds a short delay before reloading. That way the Directus flow has time to fire and bust the cache so edits actually show up in the visual editor after saving.
Should this be in the library itself? The 1-second hard limit works when Directus is already loaded but it silently breaks when the iframe loads faster than the parent module. At least a console warning when `receiveConfirm()` times out might be useful. Getting back `undefined` with nothing in the console makes it pretty difficult to debug (even with AI!).
## Setup
- `@directus/visual-editing` v1.2.0
- Next.js 16 (App Router, ISR)
- Directus 11.5.4 (official Directus Railway template)
1 Like