Skip to content
14 changes: 13 additions & 1 deletion apps/posts/parts/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,19 @@ export function createPostsApp( id: string, options: PostsAppOptions = {} ) {
);
const url = ( ctx.extra as ListExtra ).newPostUrl ?? '';
if ( host && url ) {
ui.editor = window.wp?.os?.embedAdminPage?.( host, url ) ?? null;
ui.editor =
window.wp?.os?.embedAdminPage?.( host, url, {
onAdminLink: ( _url, tab ) => {
const strip = ctx.root.querySelector<
HTMLElement & { value: string }
>( 'os-tabs' );
if ( strip ) {
strip.value = tab;
}
activateTab( ctx, ui, tab );
return true;
},
} ) ?? null;
}
};

Expand Down
33 changes: 30 additions & 3 deletions apps/posts/posts.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**

Check warning on line 1 in apps/posts/posts.test.ts

View workflow job for this annotation

GitHub Actions / Lint · Types · Vitest

This file is 1012 lines — past the 1000-line comfort zone. Be smart, build robust software: modules of ~300–600 lines are easier to read, test, review and reuse. Consider splitting this one along its natural seams — future you will say thanks
* Posts app — the client view: the frame (tabs, toolbar, bulk bar,
* table, pager, the phone footer), the table wiring and the mounted /
* updated lifecycle, the column builders and hook registries, the
Expand Down Expand Up @@ -220,11 +220,21 @@
* embed, a count of teardowns, and the undo.
*/
function stubEmbed() {
const embedded: Array< [ HTMLElement, string ] > = [];
const embedded: Array<
[
HTMLElement,
string,
{ onAdminLink?: ( linkUrl: string, targetTab: string ) => boolean }?,
]
> = [];
let teardowns = 0;
const os = ( window as unknown as { wp?: { os?: Record< string, unknown > } } ).wp?.os;
os!.embedAdminPage = ( host: HTMLElement, url: string ) => {
embedded.push( [ host, url ] );
os!.embedAdminPage = (
host: HTMLElement,
url: string,
opts?: { onAdminLink?: ( linkUrl: string, targetTab: string ) => boolean },
) => {
embedded.push( [ host, url, opts ] );
return () => {
teardowns++;
};
Expand Down Expand Up @@ -307,6 +317,23 @@
}
} );

it( 'embedded editor onAdminLink switches back to the posts tab', () => {
const embed = stubEmbed();
try {
const { root } = mount();
const strip = root.querySelector( 'os-tabs' ) as HTMLElement & { value: string };
( root.querySelector( '[data-os-posts-new]' ) as HTMLElement ).click();
expect( strip.value ).toBe( 'new' );

const onAdminLink = embed.embedded[ 0 ][ 2 ]?.onAdminLink;
expect( typeof onAdminLink ).toBe( 'function' );
expect( onAdminLink!( 'http://x.test/wp-admin/edit.php', 'posts' ) ).toBe( true );
expect( strip.value ).toBe( 'posts' );
} finally {
embed.restore();
}
} );

it( 'a draft in progress is kept, and closing the window asks first', async () => {
const embed = stubEmbed();
const bus = stubFilters();
Expand Down
2 changes: 1 addition & 1 deletion docs/api-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The full surface is documented in [`javascript-reference.md`](./javascript-refer
| `windowManager` | `WindowManager` instance | Stable |
| `openWindow` | `( id: string, opts?: { source?: string } ) => boolean` | Stable |
| `openNewWindow` | `( id: string, opts?: { source?: string } ) => boolean` *(always spawns a new instance)* | Stable |
| `embedAdminPage` | `( host: HTMLElement, url: string, opts?: { windowId?: string } ) => () => void` *(a wp-admin screen inside a native window's body)* | Stable |
| `embedAdminPage` | `( host: HTMLElement, url: string, opts?: { windowId?: string, onAdminLink?: ( linkUrl: string, tab: string ) => boolean } ) => () => void` *(a wp-admin screen inside a native window's body)* | Stable |
| `registerWindow` | `( def: NativeWindowDef ) => Promise<Window>` | Stable *(returns a `Promise`)* |
| `cloneTemplate` | `( templateOrId: string \| HTMLTemplateElement ) => DocumentFragment` | Stable |
| `onWindow` | `( id, handlers, opts? ) => () => void` | Stable |
Expand Down
4 changes: 2 additions & 2 deletions docs/bridge-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,9 @@ The chromeless bridge intercepts every same-origin `<a href="/wp-admin/…">` cl
| `os-iframe-offsite-redirect` | iframe → parent | `{ url }` | Sent instead of a window's redirect to another host, such as WooCommerce.com's **Connect account** (`openstation_chromeless_hand_off_offsite_redirect()`). The shell drops the window and takes the tab to `url`, as classic admin would; the return to an admin URL comes back through the portal. Before any user interaction (a window restored at boot) it only drops the window. Only http(s) URLs on another origin are followed; a parent that does not answer leaves a `target="_top"` link to `url`. |
| `os-iframe-admin-link` | iframe → parent | `{ url, label, newContext }` | Posted from the chromeless bridge's link interceptor for every admin-internal click that survived the modifier-key / target / download filters. `label` is the clicked link's visible text (falling back to its `title` / `aria-label`, truncated to 80 chars). `newContext` is true when the link named another browsing context, and bars the parent from driving the source window with it, closing it on a native remap included. The bridge `preventDefault`s the click first; the parent owns the navigation. The plugins screen posts one too, with `newContext: true`, for a screen a plugin redirected a window to (see `openstation_chromeless_hand_off_plugins_redirect()`). |

Parent dispatch (in `src/window/iframe-bridge.ts`, wired by `bindAdminLinkDispatch` in `desktop.ts`):
Parent dispatch (in `src/window/iframe-bridge.ts`, wired by `bindAdminLinkDispatch` in `desktop.ts`; synthetic iframes in native windows route through `native-windows.ts` via `onAdminLink` first):

1. **Native-window remap** — the URL goes through `tryNativeUrlRemap`. On a hit the parent opens the native window and closes the source iframe so the brief in-flight nav never paints.
1. **Native-window remap** — the URL goes through `tryNativeUrlRemap`. On a hit the parent opens the native window and closes the source iframe so the brief in-flight nav never paints. For synthetic iframes in native window panels (`wp.os.embedAdminPage`), a URL matching the window's own `baseId` delegates to the host app's `onAdminLink( linkUrl, tab )` callback instead of opening a second window.
2. **Same-slug click** — `deriveWindowId(url, adminUrl)` matches the source window's `baseId` **or** the slug its iframe is currently showing (`getCurrentUrl()`). The parent calls `iframe.contentWindow.location.assign(url)`, which navigates the iframe in place AND adds a session-history entry. Pagination, list filtering, and per-window tab strips ride this path.

Both slugs count because they diverge as soon as the iframe navigates in place: clicking the Menus tab in the Appearance window points the iframe at `nav-menus.php` while the window keeps `baseId: themes-php`. Matching only `baseId` would classify the Menus screen's own tab links as cross-page and spawn a fresh window per click. The live slug only ever *widens* the same-page set — it never turns an in-place navigation into a new window, so a window that has navigated away still treats a link back to its landing page as in-page.
Expand Down
5 changes: 4 additions & 1 deletion docs/examples/native-posts.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ const unsub = wp.os.registerNativeUrlRemap( {
parsed.pathname.endsWith( '/edit.php' ) &&
parsed.searchParams.get( 'post_type' ) === 'page',
enabled: ( settings ) => settings.nativePagesEnabled === true,
params: ( _url, parsed ) => ( {
tab: 'posts',
} ),
} );
```

Returning `false` from `enabled` (or returning `false` from `matches`) lets the click fall through to the iframe path. Returning a `nativeWindowId` that isn't registered for the current user (cap-gated, opt-in-gated) also falls through — `openById()` reports `false` and the registry walks on.
Returning `false` from `enabled` (or returning `false` from `matches`) lets the click fall through to the iframe path. The `params` hook passes open-time params to the target window — a multi-tab native window uses `{ tab: 'posts' }` so in-window links, dock rows and embedded-editor back buttons land on the right tab. Returning a `nativeWindowId` that isn't registered for the current user (cap-gated, opt-in-gated) also falls through — `openById()` reports `false` and the registry walks on.

> The bundled Posts window consumes this same primitive internally (`src/native-url-remap.ts`); `wp.os.registerNativeUrlRemap` hands the same registry out to plugins.

Expand Down
7 changes: 6 additions & 1 deletion docs/javascript-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1470,14 +1470,19 @@ Mount a chromeless admin page **inside an element of a native window's body**, a
wp.os.embedAdminPage(
host: HTMLElement,
url: string,
opts?: { windowId?: string },
opts?: {
windowId?: string;
onAdminLink?: ( linkUrl: string, tab: string ) => boolean;
},
): () => void;
```

An app's window is its own surface, not an iframe, so an app offering one of wp-admin's own screens had only `open_url()` — which spawns a second window. This is for the case where that reads wrong: the Posts app's tabs are the Posts menu's pages, and its **Add Post** tab swaps the body for the editor the way every tab beside it swaps the body.

The page is registered as that window's **synthetic iframe**, so [`wp.os.connect( windowId )`](#wposconnect-windowid-opts---stable) and `Window.send()` reach it, and `os-bridge-*` traffic and `os-window-publish` route as they do for any window. The chromeless flag is added for you.

When `opts.onAdminLink` is provided, clicks inside the embedded page that remap to this window (such as the block editor's "View Posts" button) invoke `onAdminLink( linkUrl, tab )`. Returning `true` signals that the host app handled the navigation (e.g. by switching tabs via `activateTab()`), bypassing fallback window navigation.

While the page loads, the busy mark is the **host's**, not the window's: an embed occupies one panel of a body the user is still looking at, and the window overlay would black out the tab strip that put them there — including the tab they would use to leave. The helper centres an `<os-spinner>` over the host — the same WordPress mark, at the same size, as a window's own loading overlay — and removes it on load, styling it inline so no selector of the shell's lands in your body.

**The page's content identity is the window's while the embed is on screen.** The `os-content-identity` the page announces (and the block editor's save-watcher re-announces after saves) is forwarded to the relations engine exactly as it is for an iframe window, so the **Preview (eye)** and revisions title-bar buttons, the Related menu and window ties all work for an embedded editor — the eye's autosave and live preview talk to the embedded iframe. When the host is hidden (another tab picked) or torn down, the window gets back the identity it had before; showing the host again re-claims it. The same applies to an `iframeContent` native window's iframe.
Expand Down
14 changes: 13 additions & 1 deletion src/desktop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**

Check warning on line 1 in src/desktop.ts

View workflow job for this annotation

GitHub Actions / Lint · Types · Vitest

This file is 5989 lines — past the 1000-line comfort zone. Be smart, build robust software: modules of ~300–600 lines are easier to read, test, review and reuse. Consider splitting this one along its natural seams — future you will say thanks
* OpenStation — Entry Point.
*
* Initializes the desktop shell, restores the user's session if one
Expand Down Expand Up @@ -29,6 +29,7 @@
import {
bindNativeUrlRemap,
isPersonViewClaimed,
OS_TAB_PARAM,
registerNativeUrlRemap,
tryNativeUrlRemap,
} from './native-url-remap';
Expand Down Expand Up @@ -782,7 +783,10 @@
embedAdminPage: (
host: HTMLElement,
url: string,
opts?: { windowId?: string },
opts?: {
windowId?: string;
onAdminLink?: ( linkUrl: string, tab: string ) => boolean;
},
) => () => void;
/**
* Load a registered native window's bundle without opening the
Expand Down Expand Up @@ -3117,6 +3121,10 @@
return ! postType || postType === 'post';
},
enabled: ( snapshot ) => snapshot.nativePostsEnabled === true,
params: ( _url, parsed ) => {
const tagged = parsed.searchParams.get( OS_TAB_PARAM );
return tagged ? { tab: tagged } : { tab: 'posts' };
},
} );

// Native Pages window — same shape as the Posts remap, scoped to
Expand All @@ -3133,6 +3141,10 @@
return parsed.searchParams.get( 'post_type' ) === 'page';
},
enabled: ( snapshot ) => snapshot.nativePagesEnabled === true,
params: ( _url, parsed ) => {
const tagged = parsed.searchParams.get( OS_TAB_PARAM );
return tagged ? { tab: tagged } : { tab: 'posts' };
},
} );

// Native Users window — opens on `users.php` only (the list
Expand Down
82 changes: 73 additions & 9 deletions src/native-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { recordFrameLocation, setWindowMenuPages } from './window/share-link';
import { relayIframeReport } from './window/iframe-reports';
import {
registerNativeUrlRemap,
resolveNativeUrlRemapTarget,
tryNativeUrlRemap,
unregisterNativeUrlRemap,
} from './native-url-remap';
import { setPanelTabs } from './window/tab-strip';
Expand Down Expand Up @@ -532,6 +534,40 @@ function buildIframeContentRender(
// chromeless bridge's own message listener doesn't see this
// iframe (it sits inside a native window's body, not the
// shell-managed iframe).
/**
* Route an admin link clicked inside the page: to the host app
* when it points back at this window (the block editor's "View
* Posts" / "View Pages"), to another native window when one is
* registered for it, and to a plain iframe window otherwise.
*/
const routeAdminLink = ( linkUrl: string, linkLabel: string ): void => {
const win = window.wp?.os?.windowManager?.getById?.( windowId );
const baseId = win?.config?.baseId || registeredId;
const target = resolveNativeUrlRemapTarget( linkUrl );
const tab = target?.params?.tab;
if (
target?.id === baseId &&
typeof tab === 'string' &&
cfg.onAdminLink?.( linkUrl, tab )
) {
return;
}
if ( tryNativeUrlRemap( linkUrl ) ) {
return;
}
const osApi = window.wp?.os;
if ( osApi?.deriveWindowId && osApi?.windowManager ) {
const id = osApi.deriveWindowId( linkUrl );
void osApi.windowManager.open( {
id,
baseId: id,
url: linkUrl,
title: linkLabel || linkUrl,
icon: 'dashicons-admin-generic',
} );
}
};

const onMessage = ( e: MessageEvent ): void => {
if ( ! iframe.contentWindow || e.source !== iframe.contentWindow ) {
return;
Expand Down Expand Up @@ -613,6 +649,27 @@ function buildIframeContentRender(
.identity ?? null;
syncIdentity();
}
// In-iframe admin link navigation from the synthetic iframe.
// Synthetic iframes in native window tabs lack the outer
// `Window.iframe` binding that routes through `handleWindowMessage`,
// so in-frame navigation events are handled here. Same-origin
// only, like the handlers above. The message still reaches
// `cfg.onMessage` below whichever way the link is routed.
if (
data &&
typeof data === 'object' &&
( data as { type?: string } ).type === 'os-iframe-admin-link' &&
typeof ( data as { url?: string } ).url === 'string' &&
( data as { url: string } ).url !== '' &&
e.origin === window.location.origin
) {
const label = ( data as { label?: unknown } ).label;
routeAdminLink(
( data as { url: string } ).url,
typeof label === 'string' ? label : '',
);
}

try {
cfg.onMessage?.( e.data );
} catch ( err ) {
Expand Down Expand Up @@ -674,19 +731,24 @@ function buildIframeContentRender(
* cross-origin URL, or the shell screen, mounts nothing and returns
* a no-op.
*
* @param host Element to mount into. Its contents are replaced.
* @param url Admin URL. The chromeless flag is added here.
* @param opts Mount options.
* @param opts.windowId Fallback for a host that is not inside a window
* root yet; normally resolved from the DOM.
* @param host Element to mount into. Its contents are replaced.
* @param url Admin URL. The chromeless flag is added here.
* @param opts Mount options.
* @param opts.windowId Fallback for a host that is not inside a window
* root yet; normally resolved from the DOM.
* @param opts.onAdminLink Optional callback for in-iframe admin links that
* remap to this window. Return true if handled.
* @return Teardown: removes the listeners, the synthetic-iframe
* registration and the iframe itself.
* @public
*/
export function embedAdminPage(
host: HTMLElement,
url: string,
opts: { windowId?: string } = {},
opts: {
windowId?: string;
onAdminLink?: ( linkUrl: string, tab: string ) => boolean;
} = {},
): () => void {
let chromeless: string;
try {
Expand All @@ -706,9 +768,11 @@ export function embedAdminPage(
host.replaceChildren();
const cleanups: ( () => void )[] = [];
const windowId = resolveMountedWindowId( host, opts.windowId ?? '' );
const ready = buildIframeContentRender( { url: chromeless }, cleanups, windowId )(
host,
);
const ready = buildIframeContentRender(
{ url: chromeless, onAdminLink: opts.onAdminLink },
cleanups,
windowId,
)( host );

// Styled inline, the way the iframe above is: a class of ours in
// someone else's body is a selector they never agreed to. The
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,12 @@ export interface NativeWindowIframeContent {
* speak a non-`os-window-*` postMessage protocol.
*/
onMessage?: ( payload: unknown ) => void;
/**
* Optional callback invoked when an in-iframe admin link matches
* this native window's remap registration. Returning true indicates
* the host app handled the navigation (e.g. by switching tabs).
*/
onAdminLink?: ( linkUrl: string, tab: string ) => boolean;
}

/**
Expand Down
Loading
Loading