@ai-matrx/summon
v0.1.3
Published
Render any React component by dispatch and never mount it until it is needed: single or multi instance, handles for callbacks and refs beside Redux, keep-alive and saved component state, address-bar links bound to data, optional persistence, and a full wi
Readme
@ai-matrx/summon
Summon any React component from anywhere — with its handlers, refs and state — and never mount it until it is called.
- Dispatch to open. Open a window, dialog, sheet or any component from anywhere with a hook or an action. A closed overlay puts nothing in the React tree and fetches no code.
- Single or multi instance per overlay.
- Handles beside Redux. Callbacks, event channels, one-shot requests and imperative refs live in a registry next to the store, owned by the open instance and disposed when it closes.
- Keep-alive. On close an overlay unmounts (
none), stays alive hidden (hidden), or unmounts and remembers its saved state (restore).useOverlayStateis the saved-state hook. - Address-bar links.
?panels=notes:42names an open overlay, loads its data and survives reload. - Persistence, off or on: browser-local, your own database through a small port, or
@ai-matrx/data. - Surfaces.
WindowPanelis a desktop in the browser (drag, resize, snap, arrange, tray with thumbnails, minimize, pop-out to a real browser window, three phone presentations).dialog,sheetandlite(a light floating window) share the same close binding.
The package owns its Redux slices. It runs on a private store, or mounts into the store you already have.
Install
pnpm add @ai-matrx/summon react react-dom react-redux @reduxjs/toolkit| Peer | Needed for |
|---|---|
| react ≥ 19, react-dom, react-redux ≥ 9, @reduxjs/toolkit ≥ 2 | everything except the framework-free . and ./handles entries |
| next ≥ 16 (optional) | only ./next |
| html-to-image (optional) | tray thumbnails of minimized windows (without it the tile shows no preview) |
| @supabase/supabase-js | only ./persistence/data, through @ai-matrx/data |
Entries: . (core, no React), ./handles (no React, no Redux), ./react, ./window, ./window-host (the root-mountable half of the window section: WindowsProvider, WindowTraySync, registerTrayPreview — never loads WindowPanel), ./dialog, ./sheet, ./lite, ./url, ./persistence, ./persistence/data, ./next, ./testing, ./styles.css.
Quick start
1. Provider and host, once, near the root.
"use client";
import { SummonProvider, SummonHost } from "@ai-matrx/summon/react";
import { APP_OVERLAYS } from "./overlays";
export function Root({ children }: { children: React.ReactNode }) {
return (
<SummonProvider>
{children}
<SummonHost definitions={APP_OVERLAYS} />
</SummonProvider>
);
}2. Declare an overlay once: a light definition file (safe to import anywhere) and a render module (the lazy chunk).
// notes.overlay.ts
import { defineOverlay } from "@ai-matrx/summon/react";
export interface NotesWindowData {
noteId?: string;
}
export const notesWindow = defineOverlay<NotesWindowData>()({
id: "notesWindow",
label: "Notes",
render: () => import("./notes.render"),
});
// One line per overlay: opens are type-checked and a mistyped id is a compile error.
declare module "@ai-matrx/summon" {
interface OverlayRegistry {
notesWindow: NotesWindowData;
}
}// notes.render.tsx — loaded only when the overlay is first opened
import type { OverlayRenderProps } from "@ai-matrx/summon/react";
import { WindowPanel } from "@ai-matrx/summon/window";
export default function render({ data }: OverlayRenderProps) {
const noteId = typeof data?.noteId === "string" ? data.noteId : null;
return (
<WindowPanel id="notes" title="Notes" boundToOverlay width={480} height={420}>
<NoteEditor noteId={noteId} />
</WindowPanel>
);
}data arrives as untrusted Readonly<Record<string, unknown>> | null (it crossed Redux, a URL or storage). Narrow it in the render module, or give the definition a parse(raw) that returns the typed value (or null to refuse and close with a warning).
// overlays.ts
import { notesWindow } from "./notes.overlay";
export const APP_OVERLAYS = [notesWindow];3. Open it.
import { notesWindow } from "./notes.overlay";
function OpenNotes() {
const open = notesWindow.useSummon();
return <button onClick={() => open({ data: { noteId: "42" } })}>Notes</button>;
}
// Declarative: opens on mount, closes on unmount, re-opens when the (serialisable) props change.
const Always = () => <notesWindow.Controller noteId="42" />;open() returns { overlayId, instanceId, close() }. The definition also gives you useIsOpen(instanceId?), useClose() and, inside the render tree, useEmit().
Definition options
| Field | Meaning |
|---|---|
| id, label | id is the registry key; label shows in the tray, menus and diagnostics |
| instance | "single" (default, instance id "default") or "multi" (a fresh id per open, or pass instanceId) |
| keepAlive | "none" (default), "hidden" or "restore" |
| closesOnNavigation | close when the route changes (the overlay shows a piece of the page that opened it) |
| defaults | merged under every open's data |
| window | { mobile, mobileSidebarAs, ephemeral, preservation, restoreDefaults, windowId, deprecated, unbiddenHome } for window overlays |
| link | { key, aliases?, hydrate }, see Links |
| parse | narrow raw data once; null refuses |
| render | () => import("./x.render"); optional when the host supplies it (see below), and wins over the host map when present |
Minimizing a window always keeps its component mounted, whatever keepAlive says.
Keep-alive and saved state
import { useOverlayState, useOverlayInstance } from "@ai-matrx/summon/react";
function Draft() {
const [text, setText] = useOverlayState("draft", ""); // like useState; scoped to this open instance
const { close, hidden } = useOverlayInstance();
return <textarea value={text} onChange={(e) => setText(e.target.value)} />;
}keepAlive: "hidden": closing hides the live component (<Activity mode="hidden">); reopening shows the same instance. At mostkeepAliveLimit(default 8) closed instances stay mounted; the oldest is evicted and released.keepAlive: "restore": closing unmounts;useOverlayStatevalues come back on reopen (and persist when persistence is on).useOverlayStatevalues must be serialisable. Any descendant of the overlay may call it.
Callbacks without functions in Redux
Declare an event map and pass plain functions when opening. They travel through an event channel owned by the instance; only its id rides in the data.
export const uploader = defineOverlay<{ folder?: string }, { uploaded: { url: string }; cleared: void }>()({
id: "uploader", label: "Uploader", render: () => import("./uploader.render"),
});
const open = uploader.useSummon();
open({ data: { folder: "logos" }, handlers: { uploaded: (r) => setUrl(r.url) } });
// inside the overlay's render tree
const emit = uploader.useEmit();
emit("uploaded", { url });Closing the instance disposes the channel. Emitting after that is a silent no-op.
Large apps: the render map lives on the host
Every file that imports a definition to open it also imports that definition's render: () => import(…). To a bundler, each such file gains an edge to the overlay's whole window subtree. With a few overlays that is free. With hundreds of overlays and hundreds of opener files it multiplies the build graph: in AI Matrx, about 450 opener files each reached about 220 window subtrees, and a sub-app build went from about 10 s to an out-of-memory kill. This is the host-build law: never fan out import() edges.
The fix keeps "declare once" at two files plus one line. Leave render off the definitions, and give the host one id → import map, generated from the file names, that only the host imports:
// definitions/notes.overlay.ts — no render: importing it costs nothing
export const notes = defineOverlay<{ noteId?: string }>()({ id: "notes", label: "Notes" });
// definitions/render-map.ts — generated, imported ONLY by the host (behind its own lazy boundary)
import type { OverlayRenderMap } from "@ai-matrx/summon/react";
export const RENDER_MAP: OverlayRenderMap = { notes: () => import("./notes.render") };
// the host
<SummonHost definitions={APP_OVERLAYS} renders={RENDER_MAP} />OverlayRenderMap erases each module's data type (OverlayRenderProps<never>), so differently typed render modules share one map without any. An overlay that is opened with no render source at all (no render, no map entry) shows its error card (OverlayRenderMissingError, exported as MISSING_RENDER_ERROR) and logs in development. It is never a silent blank.
Mounting into an existing Redux store
import { configureStore } from "@reduxjs/toolkit";
import { summonReducers, createSummonMiddleware } from "@ai-matrx/summon";
const ports = { /* see Ports */ };
export const store = configureStore({
reducer: { ...appReducers, ...summonReducers }, // keys: overlays, windowManager, urlSync, overlayState
middleware: (getDefault) => getDefault().concat(createSummonMiddleware(ports)),
});<SummonProvider store={store} ports={ports}>…</SummonProvider>Pass the same ports object to both. The overlays slice keys are fixed. Without store, the provider makes a private store (createSummonStore({ ports })).
Provider knobs: fallbacks (loading, error components), errorReportExtras (extra controls in the error card), renderWrapper (wrap every rendered overlay), keepAliveLimit (8), importTimeoutMs (12 s; a render module that has not loaded becomes a ChunkLoadError with Try again), stallWarnMs (10 s).
Ports
Every port has a working default, so a bare host needs none. Override only what you have; pass them as ports on the provider (a partial object).
| Port | Shape | Default |
|---|---|---|
| identity | current(): { key, ready }, subscribe(listener) | anonymous, always ready. A changing key swaps all saved state |
| router | pathname(), search(), replaceSearch(next), subscribe(listener) | window.location + history.replaceState (inert on the server) |
| notify | info, warn, error(message, options?), optional dismiss(id) | console |
| errors | capture(error, context) | console.error |
| debug | enabled(), optional subscribe | off |
| persistence | an OverlayPersistencePort, or null | null (persistence off) |
Next.js: import { useNextRouterPort } from "@ai-matrx/summon/next" returns a stable RouterPort (call it inside a client component under Suspense; it reads useSearchParams). Testing fakes: see Testing.
Links
OverlayLinkManager keeps ?panels= and open overlays in sync through ports.router. Mount it once inside the provider.
import { OverlayLinkManager, useOverlayLink } from "@ai-matrx/summon/url";
<SummonProvider><OverlayLinkManager /><SummonHost definitions={APP_OVERLAYS} /></SummonProvider>Give the definition a link:
defineOverlay<NotesWindowData>()({
id: "notesWindow", label: "Notes", render: () => import("./notes.render"),
link: {
key: "notes",
hydrate: ({ id, args, open }) => open({ data: { noteId: id } }), // return "refused" to drop the token
},
});- Opening
/page?panels=notes:42runshydrate({ id: "42", args, open, dispatch }).hydratemay be async (load a record, thenopen). - A mounted overlay publishes its own address with
useOverlayLink("notes", noteId, { tab: "history" }).WindowPaneldoes it for you from itsurlSyncKey,urlSyncIdandurlSyncArgsprops. - Unknown keys are announced at once; tokens whose overlay never registers are announced at a deadline and kept verbatim.
- Grammar v2:
panels = token ("," token)*,token = key[:id[:k-v(_k-v)*]]. Ids are percent-encoded for% , :, arg keys and values for% , : _ -, so any value survives; v1 links still parse. Helpers:parseLinkToken,parseLinkParam,formatLinkToken,readPanelsParam,withPanelsParam. - One door:
openFromLink(searchOrUrl, { dispatch, requireAll?, onError? })returns{ opened, refused, unknown, failed }and serves the address bar, notifications and any bridge.requireAll: trueopens nothing if any key is unknown.openPanelRequest(payload, { dispatch })validates and opens a{ panelId, instanceId?, data? }request from a bridge (returns{ ok: true, … }or{ ok: false, error: "invalid_payload" | "unknown_panel" }).
Persistence
Off by default. Turn it on with a persistence port plus one mounted component:
import { createLocalPersistence, OverlayPersistence } from "@ai-matrx/summon/persistence";
const persistence = createLocalPersistence(); // create once, module level
<SummonProvider ports={{ persistence }}>
<OverlayPersistence /> {/* mount once */}
<SummonHost definitions={APP_OVERLAYS} />
</SummonProvider>What is saved: the person's open windows and layout (the workspace), and the component state of instances that are keepAlive: "restore" or preserved windows. Window data is saved only for the keys a window lists in window.preservation.dataKeys (default-deny); window.ephemeral: true never saves. Caps: 256 KiB workspace, 64 sessions, 32 KiB per window's data and per state entry; overruns report through ports.errors.capture (source: "overlay-persistence").
<OverlayPersistence> options: scope ("tab" one workspace per browser tab, "person" one per person; default "tab" for the local store, "person" for per-person stores), seedFromPerson (a new tab starts from the person's last layout; default true), saveDebounceMs (250), viewport. useOverlayPersistence(options) returns { hydrated }.
Browser-local store. createLocalPersistence({ namespace?, backend?, storage?, session?, locks?, idbReadBudgetMs?, isReload?, now? }): a localStorage mirror plus IndexedDB, per identity, per tab workspace with leases. It also has purgeIdentity(identityKey) for sign-out cleanup.
Your own database. Implement the port:
import type { OverlayPersistencePort } from "@ai-matrx/summon";
export const myPersistence: OverlayPersistencePort = {
perPerson: true, // true: rows are already scoped to the signed-in person (plain keys). Omit/false: keys arrive prefixed "<identityKey>/"
load: async (key) => /* { value, version } | null */ null,
save: async (key, value, options) => {
/* if options?.expectedVersion is set and differs from the stored version, throw an error named "PersistenceVersionConflictError" */
return { version: 1 };
},
remove: async (key) => {},
list: async (prefix) => [], // keys starting with prefix
};Keys written: workspace, workspace:<tabWorkspaceId> (tab scope), state:<overlayId>:<instanceId>. Values are JSON. Suggested table:
| Column | Type | |
|---|---|---|
| person_id | uuid | owner; rows are read and written only for the signed-in person |
| key | text | one of the keys above; unique with person_id |
| value | jsonb | the saved value |
| version | int | starts at 1, +1 per save (optimistic concurrency) |
On a conflict the controller re-reads the version and writes once more, so the latest device wins. Version conflicts are recognised by error name (isVersionConflict(error)).
@ai-matrx/data hosts. createDataPersistence({ db, userId?, table?, feature? }) from @ai-matrx/summon/persistence/data maps each key to a row of users.user_surface_state (feature = "summon", surface_key = key, state = value, version), through the data package's doors. The organization comes from createDb({ client, organization }) and the insert is refused when there is none.
import { createDb } from "@ai-matrx/data/db";
import { createDataPersistence } from "@ai-matrx/summon/persistence/data";
const persistence = createDataPersistence({ db: createDb({ client: supabase, organization: () => activeOrgId }) });Handles
@ai-matrx/summon/handles is framework-free. State lives in one global slot, so duplicated bundles share one registry.
callbackManager(verbs):register,registerWithContext(callback, { context, groupId, owner }),subscribe,unsubscribe,trigger,triggerWithContext,triggerGroup,triggerGroupCommand,updateProgress,createGroup,removeGroup,remove/unregister,get,has,registerWidgetHandle,ownedBy,groupMembers,disposeOwner,size,subscribeToRegistry.- Typed helpers, each accepting
{ owner }:
import { createEventChannel, getEventChannel, createRequest, getRequest, registerHandle, getHandle } from "@ai-matrx/summon/handles";
const owner = "notesWindow:default"; // overlayId:instanceId
const channel = createEventChannel<{ saved: string }>({ owner }); // many-shot; pass channel.id in open data
channel.subscribe((e) => console.log(e)); channel.emit({ saved: "42" });
getEventChannel<{ saved: string }>(channel.id)?.emit({ saved: "43" }); // overlay side
const request = createRequest<{ prompt: string }, boolean>({ prompt: "Discard?" }, { owner }); // one-shot
getRequest<{ prompt: string }, boolean>(request.id)?.resolve(true); // overlay answers
const answer = await request.promise; // rejects with HandleDisposedError if disposed unanswered
const dispose = registerHandle("editor:42", { focus() {} }, { owner }); // imperative ref
getHandle<{ focus(): void }>("editor:42")?.focus();
dispose();Owner disposal. When an instance closes, everything registered with its overlayId:instanceId as owner is disposed: pending requests reject, channels drop subscribers, handles are removed. No opener disposes by hand. Disposing a group member removes it from the group.
WindowPanel
import { WindowsProvider, WindowPanel } from "@ai-matrx/summon/window";Slots contract: the body is content only. Everything that is window chrome goes in a slot, never in the body.
| Slot | Props |
|---|---|
| Title | title, titleNode |
| Header actions | actionsLeft, actionsRight |
| Footer | footer, or zoned footerLeft / footerCenter / footerRight; footerVariant: "bar" \| "rich" |
| Left sidebar | sidebar, sidebarDefaultSize, sidebarMinSize, defaultSidebarOpen, sidebarExpandsWindow |
| Right panel | secondaryPanel, secondaryPanelOpen, secondaryPanelDefaultSize, secondaryPanelMinSize |
Close binding is required and checked by the type system: exactly one of overlayId (close that overlay), boundToOverlay (close the enclosing SummonHost instance) or onClose (page-local). A window with none of them does not compile.
Other props: id, width/height (numbers or "60vw"), minWidth/minHeight, position, fitContent, closeOnEscape, hidePopOutButton, urlSyncKey/urlSyncId/urlSyncArgs, onCollectData, captureTraySnapshot.
Behaviour you can rely on: maximize, minimize, pop-out and dock never remount the body (state, refs and effects survive; minimize keeps the body mounted and captures the tray thumbnail). Pop-out uses a real browser window (Document Picture-in-Picture where available). On phones a window renders as fullscreen, drawer or card per the definition's window.mobile.
WindowsProvider is optional; options: escapeYieldSelector (visible elements matching it own the Escape key; default [data-overlay-escape-yield]), mobileTopOffset (default var(--header-height, 0px)), selectionSurface (a component wrapping every window body, for a host's selection or right-click menu).
Also exported from ./window: WindowTraySync and registerTrayPreview (a semantic preview for a minimized tile; both also on the light ./window-host entry — mount providers from there at the app root), LayoutIcon, usePopoutControl, MobileDrawerSurface, MobileCardSurface, useWindowPanel.
Dialog, sheet, light window
import { OverlayDialog } from "@ai-matrx/summon/dialog";
import { OverlaySheet, OverlayDrawer, OverlayBottomSheet } from "@ai-matrx/summon/sheet";
import { LiteWindow } from "@ai-matrx/summon/lite";
// inside a render module: closes through the overlay instance
<OverlayDialog boundToOverlay>{/* DialogContent, DialogTitle … from @ai-matrx/design-system */}</OverlayDialog>
// page-local: you own the state
<OverlayDialog open={open} onClose={() => setOpen(false)}>…</OverlayDialog>
<LiteWindow id="inspector" title="Inspector">…</LiteWindow> {/* inside an SummonHost overlay, onClose can be omitted */}OverlayDialog, OverlaySheet, OverlayDrawer and OverlayBottomSheet take the design-system component's props plus the close binding: boundToOverlay (open while the instance is shown) or onClose (+ open). LiteWindow has drag, edge resize, Escape when topmost, focus in and out, and a bottom drawer on phones; no minimize, tray, persistence or links.
Styling
Import the structural CSS once, and make Tailwind v4 scan the package, since the components use utility classes:
@import "tailwindcss";
@import "@ai-matrx/summon/styles.css";
@source "../node_modules/@ai-matrx/summon/dist"; /* path relative to this CSS file */Colors come only from @ai-matrx/design-system tokens, so the host's theme applies. The traffic-light colors are --matrx-window-light-* custom properties you can override.
Testing
import { createTestPorts, createTestSummonStore, createMemoryPersistence } from "@ai-matrx/summon/testing";
const ports = createTestPorts({ persistence: createMemoryPersistence() });
const store = createTestSummonStore(ports);
render(<SummonProvider store={store} ports={ports}><SummonHost definitions={[notesWindow]} /></SummonProvider>);createTestPorts gives a controllable identity (set(key, ready?)), a controllable router (navigate(path, search?)) and recording notify and errors (messages, captured). createMemoryPersistence is an in-memory, versioned port. No test-runner dependency.
Gates
pnpm typecheck (also compiles the package with a host-augmented registry), npx vitest run, pnpm check:package.
