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
4 changes: 2 additions & 2 deletions docs/javascript-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -5490,8 +5490,8 @@ Lifecycle + instrumentation for the chromeless iframe inside each window. Re-dis
| Hook | Kind | Status | Payload |
|---|---|---|---|
| `os.iframe.ready` | action | Stable | `{ windowId }` — fires once per iframe when the chromeless bridge script has attached its listeners. Use this instead of the iframe's `load` event when timing matters (the native `load` fires before our bridge attaches, so messages sent on `load` can miss the listener). |
| `os.iframe.error` | action | Stable | `{ windowId, kind: 'error' \| 'unhandledrejection', message, filename, lineno, colno, stack }` — bridged from the iframe's `error` / `unhandledrejection` handlers. Cross-origin iframe errors are origin-filtered at the bridge and never reach this hook. |
| `os.iframe.network-completed` | action | Stable | `{ windowId, method, url, status, duration, failed }` — every `fetch` + `XMLHttpRequest` call inside the iframe. `status === 0` indicates a network-level failure with no response received. |
| `os.iframe.error` | action | Stable | `{ windowId, kind: 'error' \| 'unhandledrejection', message, filename, lineno, colno, stack }` — bridged from the iframe's `error` / `unhandledrejection` handlers, for an iframe window's page and for an admin page embedded in a native window (`iframeContent`, `embedAdminPage()`); `windowId` is the owning window. Cross-origin iframe errors are origin-filtered at the bridge and never reach this hook. |
| `os.iframe.network-completed` | action | Stable | `{ windowId, method, url, status, duration, failed }` — every `fetch` + `XMLHttpRequest` call inside the iframe: an iframe window's page, or an admin page embedded in a native window (`iframeContent`, `embedAdminPage()` — the Posts app's block editor included); `windowId` is the owning window. `status === 0` indicates a network-level failure with no response received. |

Use `IFRAME_READY` when you need to send a `os-focus` (or any parent→iframe message) as early as possible without racing the bridge setup. Use `IFRAME_ERROR` / `IFRAME_NETWORK_COMPLETED` to build a monitor widget that surfaces per-window reliability data.

Expand Down
12 changes: 9 additions & 3 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,12 @@ export const HOOKS = {
* `unhandledrejection` handler catches an exception. Payload: `{
* windowId: string, kind: 'error' | 'unhandledrejection', message:
* string, filename: string | null, lineno: number | null, colno:
* number | null, stack: string | null }`. Origin-filtered at the
* parent shell; cross-origin iframe errors never reach here.
* number | null, stack: string | null }`. Covers an iframe window's
* page and an admin page embedded in a native window (an
* `iframeContent` window, `embedAdminPage()` — the Posts app's
* editor); `windowId` is the owning window either way. Origin-
* filtered at the parent shell; cross-origin iframe errors never
* reach here.
*/
IFRAME_ERROR: 'os.iframe.error',
/**
Expand All @@ -247,7 +251,9 @@ export const HOOKS = {
* duration: number, failed: boolean }`. Subscribers get a faithful
* view of admin-ajax + REST calls that previously never left the
* iframe boundary. `status === 0` indicates a network failure with
* no response received.
* no response received. Covers an iframe window's page and an
* admin page embedded in a native window (the Posts app's block
* editor included); `windowId` is the owning window either way.
*/
IFRAME_NETWORK_COMPLETED: 'os.iframe.network-completed',
/**
Expand Down
10 changes: 10 additions & 0 deletions src/native-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { injectInlineScript, loadVendorScript } from './wallpapers/vendor-loader
import { registerSyntheticIframe } from './connection';
import { isShellDocumentUrl } from './shell-url';
import { recordFrameLocation, setWindowMenuPages } from './window/share-link';
import { relayIframeReport } from './window/iframe-reports';
import {
registerNativeUrlRemap,
unregisterNativeUrlRemap,
Expand Down Expand Up @@ -587,6 +588,15 @@ function buildIframeContentRender(
( data as { payload?: unknown } ).payload,
);
}
// The chromeless bridge's error and network reports, relayed
// exactly as `src/window/iframe-bridge.ts` relays them for an
// iframe window. Without this an embedded page — the Posts
// app's block editor — never reached `os.iframe.error` or
// `os.iframe.network-completed`. Same-origin only, matching
// the bridge's own trust boundary.
if ( e.origin === window.location.origin ) {
relayIframeReport( windowId, data );
}
// Content identity of the page in this iframe — the same
// announcement `src/window/iframe-bridge.ts` forwards for an
// iframe window. Same-origin only: the identity carries
Expand Down
51 changes: 4 additions & 47 deletions src/window/iframe-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { tryNativeUrlRemap } from '../native-url-remap';
import { createSharedStore } from '../shared-store';
import { matchDestructiveAdminAction } from '../destructive-admin-actions';
import { setWindowContent } from '../window-links/engine';
import { relayIframeReport } from './iframe-reports';
import { openUserFootprintWindow } from '../open-targets/footprint-target';

/**
Expand Down Expand Up @@ -493,24 +494,9 @@ export function handleWindowMessage( win: Window, event: MessageEvent ): void {
addExternalTab( win, data.url, label );
}

// Iframe error relay — the chromeless bridge posts
// `os-iframe-error` from inside the iframe's error +
// unhandledrejection handlers. We annotate with the owning
// windowId and dispatch the hook, where monitor widgets pick it
// up. Shape matches `HOOKS.IFRAME_ERROR`.
if ( data.type === 'os-iframe-error' ) {
doAction( HOOKS.IFRAME_ERROR, {
windowId: win.id,
kind: data.kind === 'unhandledrejection'
? 'unhandledrejection'
: 'error',
message: typeof data.message === 'string' ? data.message : '',
filename: typeof data.filename === 'string' ? data.filename : null,
lineno: typeof data.lineno === 'number' ? data.lineno : null,
colno: typeof data.colno === 'number' ? data.colno : null,
stack: typeof data.stack === 'string' ? data.stack : null,
} );
}
// The chromeless bridge's error and network reports — shared with
// admin pages embedded in native windows (`src/native-windows.ts`).
relayIframeReport( win.id, data );

// Layer-1 theme — iframe content can re-theme its own window
// via the bridge. `tokens` is a CSS-variable map; `setAppearanceTheme`
Expand Down Expand Up @@ -609,35 +595,6 @@ export function handleWindowMessage( win: Window, event: MessageEvent ): void {
);
}
}

// Iframe network completion — bridged from the fetch + XHR
// wrappers inside the chromeless iframe. Every completed call
// (success or failure) fires here. `status === 0` indicates a
// network-level failure before a response arrived; `failed` is
// pre-computed inside the iframe (by the chromeless bridge's
// fetch/XHR wrappers) so subscribers don't have to re-derive
// the success / 4xx / 5xx / network boundary.
if ( data.type === 'os-iframe-network' ) {
const networkPayload: Record< string, unknown > = {
windowId: win.id,
method: typeof data.method === 'string' ? data.method : 'GET',
url: typeof data.url === 'string' ? data.url : '',
status: typeof data.status === 'number' ? data.status : 0,
duration: typeof data.duration === 'number' ? data.duration : 0,
failed: !! data.failed,
};
// `requestHeaders` / `responseHeaders` only ride along when
// devtools have asked the iframe to observe (via
// `wp.os.devtools.onRequest( id, cb, { observe: true } )`).
// Default deliveries stay summary-only for privacy.
if ( data.requestHeaders && typeof data.requestHeaders === 'object' ) {
networkPayload.requestHeaders = data.requestHeaders;
}
if ( data.responseHeaders && typeof data.responseHeaders === 'object' ) {
networkPayload.responseHeaders = data.responseHeaders;
}
doAction( HOOKS.IFRAME_NETWORK_COMPLETED, networkPayload );
}
}

/**
Expand Down
44 changes: 44 additions & 0 deletions src/window/iframe-reports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* `relayIframeReport()` — the one relay both iframe windows and
* admin pages embedded in native windows use for the chromeless
* bridge's `os-iframe-error` / `os-iframe-network` reports.
*/
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { addAction, HOOKS } from '../hooks';
import { clearHooksStub, installHooksStub } from '../../tests/vitest/helpers/hooks-stub';
import { relayIframeReport } from './iframe-reports';

beforeEach( () => installHooksStub() );
afterEach( () => clearHooksStub() );

describe( 'relayIframeReport', () => {
test( 'normalises a network report and keeps headers only when present', () => {
const seen: unknown[] = [];
addAction( HOOKS.IFRAME_NETWORK_COMPLETED, 'test', ( p: unknown ) => seen.push( p ) );

expect( relayIframeReport( 'w1', { type: 'os-iframe-network', status: 'nope', failed: 1 } ) ).toBe( true );
expect( relayIframeReport( 'w1', { type: 'os-iframe-network', url: '/a', status: 503, duration: 12, requestHeaders: { a: '1' } } ) ).toBe( true );

expect( seen ).toEqual( [
{ windowId: 'w1', method: 'GET', url: '', status: 0, duration: 0, failed: true },
{ windowId: 'w1', method: 'GET', url: '/a', status: 503, duration: 12, failed: false, requestHeaders: { a: '1' } },
] );
} );

test( 'normalises an error report', () => {
const seen: unknown[] = [];
addAction( HOOKS.IFRAME_ERROR, 'test', ( p: unknown ) => seen.push( p ) );

relayIframeReport( 'w2', { type: 'os-iframe-error', kind: 'unhandledrejection', message: 'boom', lineno: 'x' } );

expect( seen ).toEqual( [
{ windowId: 'w2', kind: 'unhandledrejection', message: 'boom', filename: null, lineno: null, colno: null, stack: null },
] );
} );

test( 'anything else is not a report', () => {
expect( relayIframeReport( 'w3', { type: 'os-content-identity' } ) ).toBe( false );
expect( relayIframeReport( 'w3', null ) ).toBe( false );
expect( relayIframeReport( 'w3', 'os-iframe-network' ) ).toBe( false );
} );
} );
84 changes: 84 additions & 0 deletions src/window/iframe-reports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Relay of the chromeless bridge's error and network reports.
*
* The chromeless bridge wraps `fetch` / `XMLHttpRequest` and listens
* for `error` / `unhandledrejection` inside every admin page the shell
* frames, posting `os-iframe-network` and `os-iframe-error` to the
* parent. Two kinds of frame carry such a page:
*
* - an iframe window's own `Window.iframe` (`src/window/iframe-bridge.ts`);
* - an admin page embedded in a NATIVE window's body — an
* `iframeContent` window, or `embedAdminPage()` (the Posts app's
* editor tab) — whose `Window.iframe` is null
* (`src/native-windows.ts`).
*
* Both hand verified messages (same origin, from their own frame) to
* {@link relayIframeReport}, so the two paths dispatch the same hooks
* with the same payloads. Before the native path called it, an
* embedded page's reports were dropped: the block editor's REST,
* autosave and Heartbeat traffic and its JavaScript errors never
* reached `os.iframe.network-completed` / `os.iframe.error`.
*/

import { doAction, HOOKS } from '../hooks';

/**
* Dispatch the hook for a bridge report, annotated with the owning
* window. The caller has already checked the message's origin and
* source.
*
* @param windowId Window the reporting frame belongs to.
* @param data The message's `data`.
* @return Whether `data` was a report (and a hook fired).
*/
export function relayIframeReport( windowId: string, data: unknown ): boolean {
if ( ! data || typeof data !== 'object' ) {
return false;
}
const d = data as Record< string, unknown >;

// `os-iframe-error` from inside the page's error +
// unhandledrejection handlers. Shape matches `HOOKS.IFRAME_ERROR`.
if ( d.type === 'os-iframe-error' ) {
doAction( HOOKS.IFRAME_ERROR, {
windowId,
kind: d.kind === 'unhandledrejection' ? 'unhandledrejection' : 'error',
message: typeof d.message === 'string' ? d.message : '',
filename: typeof d.filename === 'string' ? d.filename : null,
lineno: typeof d.lineno === 'number' ? d.lineno : null,
colno: typeof d.colno === 'number' ? d.colno : null,
stack: typeof d.stack === 'string' ? d.stack : null,
} );
return true;
}

// Network completion from the fetch + XHR wrappers. Every completed
// call (success or failure) fires here. `status === 0` is a
// network-level failure before a response arrived; `failed` is
// pre-computed inside the page so subscribers don't re-derive the
// success / 4xx / 5xx / network boundary.
if ( d.type === 'os-iframe-network' ) {
const payload: Record< string, unknown > = {
windowId,
method: typeof d.method === 'string' ? d.method : 'GET',
url: typeof d.url === 'string' ? d.url : '',
status: typeof d.status === 'number' ? d.status : 0,
duration: typeof d.duration === 'number' ? d.duration : 0,
failed: !! d.failed,
};
// `requestHeaders` / `responseHeaders` only ride along when
// devtools have asked the page to observe (via
// `wp.os.devtools.onRequest( id, cb, { observe: true } )`).
// Default deliveries stay summary-only for privacy.
if ( d.requestHeaders && typeof d.requestHeaders === 'object' ) {
payload.requestHeaders = d.requestHeaders;
}
if ( d.responseHeaders && typeof d.responseHeaders === 'object' ) {
payload.responseHeaders = d.responseHeaders;
}
doAction( HOOKS.IFRAME_NETWORK_COMPLETED, payload );
return true;
}

return false;
}
52 changes: 52 additions & 0 deletions tests/vitest/embed-admin-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
LOADING_SPINNER_SIZE,
} from '../../src/window/constants';
import { clearHooksStub, installHooksStub } from './helpers/hooks-stub';
import { addAction, HOOKS } from '../../src/hooks';

const WINDOW_ID = 'desktop-mode-posts';

Expand Down Expand Up @@ -162,3 +163,54 @@ describe( 'embedAdminPage', () => {
teardown();
} );
} );

describe( 'the embedded page’s error and network reports', () => {
/** A message from inside the embedded page, as the chromeless bridge posts it. */
function post( iframe: HTMLIFrameElement | Window | null, data: unknown, origin = window.location.origin ): void {
window.dispatchEvent(
new MessageEvent( 'message', {
data,
origin,
source: iframe instanceof HTMLIFrameElement ? iframe.contentWindow : iframe,
} ),
);
}

test( 'fetch / XHR completions reach os.iframe.network-completed, tagged with the window', () => {
const seen: unknown[] = [];
addAction( HOOKS.IFRAME_NETWORK_COMPLETED, 'test', ( payload: unknown ) => seen.push( payload ) );
const { iframe, teardown } = mount();

post( iframe, { type: 'os-iframe-network', method: 'post', url: '/wp-json/wp/v2/posts/7', status: 200, duration: 84, failed: false } );

expect( seen ).toEqual( [
{ windowId: WINDOW_ID, method: 'post', url: '/wp-json/wp/v2/posts/7', status: 200, duration: 84, failed: false },
] );
teardown();
} );

test( 'errors reach os.iframe.error, tagged with the window', () => {
const seen: Array< { windowId: string; message: string } > = [];
addAction( HOOKS.IFRAME_ERROR, 'test', ( payload: { windowId: string; message: string } ) => seen.push( payload ) );
const { iframe, teardown } = mount();

post( iframe, { type: 'os-iframe-error', kind: 'error', message: 'TypeError: x is undefined' } );

expect( seen ).toEqual( [ expect.objectContaining( { windowId: WINDOW_ID, message: 'TypeError: x is undefined' } ) ] );
teardown();
} );

test( 'reports from another frame, another origin, or after teardown are ignored', () => {
const seen: unknown[] = [];
addAction( HOOKS.IFRAME_NETWORK_COMPLETED, 'test', ( payload: unknown ) => seen.push( payload ) );
const { iframe, teardown } = mount();
const report = { type: 'os-iframe-network', url: '/x', status: 200 };

post( window, report );
post( iframe, report, 'https://evil.example' );
teardown();
post( iframe, report );

expect( seen ).toEqual( [] );
} );
} );
Loading