Skip to content

example render ctx

github-actions[bot] edited this page Aug 25, 2026 · 2 revisions

The native-window render ctx

Native windows registered via openstation_register_window() (or wp.os.registerWindow()) get a render callback. The callback receives a second ctx argument carrying the channel API and the rest of the window-scoped helpers — a close-bound AbortSignal, lazy resize/hide/show subscribers, and top-level markLoading/markReady aliases for the loading-overlay controls.

render: (
    body: HTMLElement,
    ctx: NativeRenderContext,
) => void | ( () => void ) | Promise< void | ( () => void ) >;

Legacy unary callbacks (render: ( body ) => …) keep working — the second arg is optional and JS just ignores extras.

What's on ctx

Field Type Use
signal AbortSignal Aborts when the window starts closing. Pass to wp.os.fetch( url, { signal } ) so in-flight requests cancel.
onResize( cb ) ( cb: ( w, h ) => void ) => () => void Subscribe to body-resize events for this window. Returns an unsubscribe; auto-detaches on close.
onHide( cb ) ( cb: () => void ) => () => void Fires when the window is minimized. Pause animations/intervals here.
onShow( cb ) ( cb: () => void ) => () => void Fires when the window is restored. Resume what onHide paused.
markLoading() () => void Re-show the loading overlay (e.g. before a refetch).
markReady() () => void Hide the overlay + fade body in.
window.send( channel, payload? ) typed pub Publish on this window's channel — every Window.on(channel, cb) subscriber sees it.
window.on( channel, cb ) typed sub Subscribe to messages sent FROM outside via Window.send().

markLoading / markReady exist both at the top level (ctx.markLoading()) and under ctx.window (ctx.window.markLoading()). The top-level shape exists for { markLoading, markReady, signal, onResize } destructuring; ctx.window is the original surface and stays.

Recipe — feed reader that cancels on close, pauses while hidden

window.openStationNativeWindows = window.openStationNativeWindows || {};
window.openStationNativeWindows[ 'my-feed-inbox' ] = async (
    body,
    { signal, onResize, onHide, onShow, markLoading, markReady, window: ch },
) => {
    const list = body.querySelector< HTMLElement >( '.feed' )!;
    let paused = false;
    let cursor: string | undefined;

    async function loadPage() {
        markLoading();
        try {
            const res = await wp.os.fetch(
                '/wp-json/my-feed/v1/items?cursor=' + ( cursor ?? '' ),
                { signal },
            );
            if ( signal.aborted ) return;
            const json = await res.json();
            cursor = json.nextCursor;
            renderItems( list, json.items );
        } catch ( err ) {
            if ( ( err as DOMException ).name === 'AbortError' ) return;
            throw err;
        } finally {
            if ( ! signal.aborted ) markReady();
        }
    }

    onResize( ( w ) => list.style.setProperty( '--feed-width', w + 'px' ) );
    onHide( () => { paused = true; } );
    onShow( () => { paused = false; loadPage(); } );

    // Listen for parent → window pushes, e.g. someone clicked
    // "Refresh" from a sibling toolbar.
    ch.on( 'feed:refresh', () => { cursor = undefined; loadPage(); } );

    await loadPage();

    // Optional: a render-returned teardown still runs at close —
    // useful for resources the framework can't track on its own.
    return () => {
        // No need to abort `signal` here — the framework already did.
    };
};

What "auto-detach" means

The framework stores the ctx's disposer on the Window instance and runs it pre-animation when the window closes:

  1. controller.abort() fires on ctx.signal.
  2. Every onResize/onHide/onShow subscription is removed.

The user's render-returned teardown runs AFTER the closing animation. So async paths inside the teardown that branch on signal.aborted already see the flipped value.

Close is not the only unmount. The ⋯ menu's Reload row (and wp.os.windowManager.getById( id ).reload()) runs the same disposal on a native window and then renders again into an emptied body with a brand-new ctx. The ordering differs in one way that matters: on reload the render-returned teardown runs before the body is emptied, so a teardown that reads its own DOM still finds it. Everything the framework tracks — signal, ch.on, onResize/onHide/onShow — is torn down for you either way; anything you registered outside the body needs the teardown to return it, or you leak a copy per reload.

Backwards compatibility

  • Existing unary ( body ) => … callbacks: continue to work. JS ignores the extra arg.
  • Existing callers using ctx.window.markLoading() (the original surface): still work — that surface is unchanged.
  • WindowConfig.onResize (registration-time field): still fires alongside ctx.onResize. Use whichever fits your code shape — the registration-time field is an inline bag for plugins that prefer not to subscribe inside the render body.

See also

Home

Guides

Migration notes

Examples

All examples

Clone this wiki locally