-
Notifications
You must be signed in to change notification settings - Fork 41
example keyed list
Stable.
If your plugin paints a list (chat rows, log entries, badges, search
results, anything observable) into a DOM container, and the data can
change while the user is looking at it, you have a subtle race
condition waiting to bite you. renderKeyedList() is the framework
helper that prevents it.
Naive list rendering uses host.innerHTML = '' followed by a full
rebuild on every state change. That destroys every row's DOM node
and creates fresh ones — same content, different element instances.
If a re-render lands between the user's mousedown and mouseup on
a row, the browser does NOT synthesize a click event, because
mousedown and mouseup ended up on different elements. The user's
click silently does nothing. The row "flashes" via :hover and
reverts.
A typical case: a heartbeat-driven list (presence, inbox, log stream) re-renders every few seconds. Without keyed reconciliation the re-render destroys whichever row the user is mid-press on, and their click silently does nothing.
Reuse DOM nodes across renders by matching on a stable key. Same key
→ same <li>. Different key → only the affected nodes are created
or removed. Listeners attached when a node was first built survive
every subsequent re-render.
import { renderKeyedList } from 'openstation';
const host = document.querySelector( '#my-list' )!;
function repaint(): void {
renderKeyedList( host, getCurrentItems(), {
keyOf: ( item ) => item.id,
buildItem( item ) {
const li = document.createElement( 'li' );
li.dataset.id = String( item.id );
li.addEventListener( 'click', () => onSelect( item ) );
// Initial population happens here too.
const label = document.createElement( 'span' );
label.textContent = item.title;
li.appendChild( label );
return li;
},
updateItem( el, item, prev ) {
// Refresh whatever may have changed since prev.
const label = el.querySelector< HTMLElement >( 'span' );
if ( label ) label.textContent = item.title;
},
} );
}
// Wire to your store / event bus / heartbeat / poller.
store.subscribe( repaint );The <li> for item.id === 42 is the SAME DOM node every render.
The click listener attached in buildItem keeps firing forever —
no mid-press race, no document-level capture delegation needed, no
mousedown workaround.
function renderKeyedList< T >(
host: HTMLElement,
items: readonly T[],
opts: KeyedListOptions< T >,
): void;
interface KeyedListOptions< T > {
/** Stable identity. Same key = same DOM. */
keyOf( item: T ): string | number;
/** Build the DOM the first time we see a key. Attach listeners here. */
buildItem( item: T ): HTMLElement;
/** Optional: refresh existing DOM when data changed but key didn't. */
updateItem?( el: HTMLElement, item: T, prevItem: T | null ): void;
}
function clearKeyedList( host: HTMLElement ): void;- The host is owned by the reconciler. Don't mix in hand-managed children — they'll be removed on the next render.
- Reorder is in-place. Swapping two items moves only those nodes; unchanged neighbours stay put.
- Steady state is free. A re-render with the exact same items in the exact same order does zero DOM writes.
- Conversation lists, message threads, log streams, notification badges — anywhere observable data drives a list.
- Any time you'd otherwise be tempted to write
host.innerHTML = ''; for (...) host.appendChild(...)and the data can change while the user is interacting.
- One-shot static lists. Just
appendChildis simpler. - Lists rendered through a framework's own diffing engine (Lit, Preact, etc.). Use the framework's keyed-list directive instead; doubling up wastes work.
-
docs/javascript-reference.md— thewp.os.renderKeyedList()/clearKeyedList()API reference.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config