Skip to content

Client API Context -- Hook based Graphistry embed#193

Draft
exrhizo wants to merge 20 commits intomasterfrom
feat/external-bridge
Draft

Client API Context -- Hook based Graphistry embed#193
exrhizo wants to merge 20 commits intomasterfrom
feat/external-bridge

Conversation

@exrhizo
Copy link
Copy Markdown
Contributor

@exrhizo exrhizo commented Apr 20, 2026

Modern dev ergonomics

Warning: Slop Quality level -- for demonstration purposes only

Simple App.tsx

The entire host integration fits in a provider and two panels:

<GraphistryProvider host={HOST} dataset={DATASET} params={SCENE_PARAMS} bg="#0A0814">
  <GraphistryScene className="absolute inset-0 size-full" />
  <SelectionInspector />
  <FilterBar />
</GraphistryProvider>

A panel is data + UI, nothing else:

function FilterBar() {
  const filters = useFilters();   // { filters, add, remove, reset, ready, error }
  return (
    <Panel title="Filters" collapsible>
      <Input onSubmit={() => filters.add(expr)} />
      <Button onClick={() => filters.reset()}>Reset</Button>
      {filters.filters.map((f) => (
        <Chip onRemove={() => filters.remove(f.id)}>{filterDisplay(f)}</Chip>
      ))}
    </Panel>
  );
}
image image

No subscription wiring. No falcor paths to know. No rxjs. useFilters() returns a typed snapshot and Promise-returning mutators; everything reactive is handled by the provider.

SelectionInspector follows the same shape — useSelection(){ points, edges, pointLabels, edgeLabels, ready } — and the single-node case surfaces the full column map with { withColumns: true } opted in at the provider registry.

What's under the covers

  • Host side (projects/client-api/src/)

    • rpc.js — correlation-id RPC client (call / get / set) with typed errors.
    • subscriptions.jsSubscriptionManager with init-replay (cold-load subscribes land after iframe's listener exists), per-path JSON dedupe (kills hover-driven re-render flash), options pass-through, refresh(path) for post-mutation resubscribe, and an iframe-log relay so iframe diagnostics surface in the host console.
    • externalStore.js — shallow-eq + refcount store.
    • snapshots.js — projections + canonical fragment pathSets.
  • React side (projects/client-api-context/src/)

    • GraphistryProvider — handshake, rpc lifecycle, optional bg / filters / exclusions props, controlled-vs-uncontrolled domain flags.
    • GraphistryScene — the iframe element; just a stable ref-callback into the provider.
    • Hooks: useGraphistry, useGraphistryScene, useSelection, useLabels, useFilters. All typed.

Pairs with

  • graphistry/graphistry#3088 — the iframe-side half (Protocol v2 wire format + whitelist entries + the iframe RPC set path + the redux-dispatch nudge that repaints pull-based viz containers after RPC writes). The two PRs are designed to land together; neither does anything useful alone.

exrhizo and others added 17 commits April 19, 2026 17:05
Empty React library skeleton (stubs only) and a Vite+React-18
smoke-test app. Implementation lands in follow-ups — see
graphistry/ai_code_notes/architecture/external_bridge.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the package name with the existing client-api / client-api-react
naming convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds createRpcClient(iframe) — a Promise-returning thin wrapper over the
Protocol v2 correlation-id envelope. Exposes three primitives matching
the iframe-side dispatcher: call(path, args), get(...paths), set(json).
Rejects with GraphistryRpcError carrying a typed kind ('invalid_op' |
'internal' | 'timeout' | 'disposed'), so consumers can distinguish
failure modes without parsing message strings.

Bumps CLIENT_SUBSCRIPTION_API_VERSION to 2 to declare support for the
new envelope on the handshake.

No rxjs in the RPC path — a junior dev wires up addFilter() without
learning Observables.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…primitives

Adds the protocol-level bits that both the React wrapper and non-React
consumers need:

- ExternalStore<T>: shallow-eq with explicit arrayKeys, refcount hooks
  on 0→1 and 1→0 so callers can drive subscribe/unsubscribe messages
  lazily. Bound subscribe/getSnapshot so React's useSyncExternalStore
  can adopt it without wrapping.
- SubscriptionManager: registers (path → store + projection), refcounts
  listeners, emits graphistry-subscribe/unsubscribe, routes
  graphistry-sub-update + graphistry-sub-error to the right store.
- snapshots.js: SelectionSnapshot / LabelsSnapshot / FiltersSnapshot
  shapes + projection fns that normalise falcor pseudo-arrays and swap
  local row indices for stable globalIndex. Projected once at the
  update boundary so consumers never see $refs.
- GraphistryPermissionError: typed error for subscribe paths gated by
  server feature flags (esp. flag_unsafe_jsapi_export_row).
- Path constants PATH_SELECTION_LABELS / PATH_LABELS / PATH_FILTERS so
  host and iframe agree on wire strings.

No React dependency in any of these modules — the React bits live in
@graphistry/client-api-context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin React layer over @graphistry/client-api primitives:

- GraphistryProvider: owns SubscriptionManager + RpcClient + init
  handshake + controlled-domain set. Build scene URL from host/dataset
  props with a `src` escape hatch.
- GraphistryScene: renders the iframe, registers it with the Provider
  on mount, tears down on unmount. useGraphistryScene() exposed for
  hand-rolled iframes.
- useSelection / useLabels / useFilters: thin useSyncExternalStore
  wrappers that surface typed snapshots with ready/error flags. Users
  never see useSyncExternalStore, falcor, or rxjs.
- useGraphistry(): imperative handle with addFilter / resetFilters /
  setSelectionExternal convenience methods; `rpc` escape hatch for
  raw call/get/set against the whitelisted model.
- GraphistryControlledError: thrown when a hook mutator is called on
  a domain controlled by a Provider prop (filters, exclusions, …).
  Same one-way-at-a-time rule as the existing <Graphistry> component.

Example app exercises all four hooks, catches the three error classes,
and is parameterizable by ?host=…&dataset=… query string.

Drops rxjs from the dep list — nothing in this package uses it
directly anymore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client side of the Protocol v2 generic subscribe.

client-api:
- SubscriptionManager.register accepts optional pathSets; when set, the
  graphistry-subscribe postMessage includes them and the iframe routes
  to its generic model.get-based handler.
- FRAGMENT_FILTERS: canonical pathSet for the filters domain
  (workbooks.open.views.current.filters[0..30].{id,query,enabled,name,
   dataType,level} + length).
- projectFilters updated for the post-walk shape — iframe walks the
  static prefix, so the projection receives the filters pseudo-array
  directly, not the full jsonGraph root.

client-api-context:
- Provider registers the filters store with FRAGMENT_FILTERS. No
  viz-side container needed — useFilters() just works once both
  branches are deployed.
- Ambient types updated with FalcorPathSet + FRAGMENT_FILTERS.

v1 paths (selection.labels, labels) continue to register without
pathSets; iframe falls through to the hand-enriched handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upgrades the smoke-test app:

- React 19, @vitejs/plugin-react 6, Vite 8.
- Tailwind 4 via @tailwindcss/vite — CSS-first config (no config file,
  no PostCSS step). Dark theme defined in src/index.css via @theme.
- vite-plugin-console-forward streams browser console.* and unhandled
  errors to the dev-server stdout, so server-side coding agents see
  runtime behavior without browser devtools.
- App.tsx rewritten with Tailwind: iframe fills the left pane, 420px
  sidebar on the right with Selection + Filters cards, status dots,
  input + buttons styled with focus rings, error pills. Monospace for
  technical values.

No code changes in the hooks or provider — this is pure example-app
dressing + upstream bumps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GraphistryScene's ref callback depended on the full ctx object, which
gets a new reference every time Provider's value memo recomputes (i.e.,
every setRpc). In React 19 that triggered the ref-reattach cycle:
setRef regenerates → React detaches old ref → calls old(null) →
registerIframe(null) → setRpc(null, dispose) → re-render → loop until
"Maximum update depth exceeded". Pulling the stable pieces
(registerIframe, sceneSrc) out of ctx makes setRef's identity stable.

Also splits the module so Fast Refresh works when editing context.tsx:

- internal.ts: Ctx object + shared types (ControlledDomain,
  GraphistryHandle, InternalContext).
- context.tsx: GraphistryProvider + GraphistryScene components only.
- hooks.ts: useGraphistry, useGraphistryScene, useSelection, useLabels,
  useFilters — all in one hooks file.

React Fast Refresh bails on mixed component + non-component exports,
forcing full reloads on every tsx edit. With this split, tsx edits
hot-patch without tearing down the iframe + handshake state.

example/App.tsx: default HOST to `${location.hostname}:8491` so the
app works out-of-box against Alex's dev Graphistry instead of the
public hub (which runs pre-Protocol-v2 viz code). Override with
?host=… query param.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Redesigns the smoke-test app so our React components float as
glass-morphism cards on top of a full-bleed Graphistry iframe, instead
of a side-by-side split. Graphistry's own chrome is disabled via
URL params passed through Provider.params (menu=false, info=false —
see apps/core/viz/src/containers/view.js), combined with play=5000 to
auto-start layout and skip the splash.

Tailwind 4 @theme tokens: dashboard.{dark,surface,header,border} for
chrome + brand.{teal,purple,green} for accents. Selection point/edge
counts colored purple/teal to match the palette.

Default dataset bumped to the arrow-format one that's seeded on Alex's
dev Graphistry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
play=5000 runs the force-layout for 5 seconds; it doesn't actually
control the splash screen. splashAfter=false is the real kill-switch
(apps/core/viz/server/splash.js:13 reads it from the query string).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operational notes for picking up the client-api-context prototype:
getting started (vite dev + package aliases), gotchas that cost time
(React 19 ref callback identity, Fast Refresh export splits, vite-
plugin-console-forward peer deps, Tailscale HMR, chrome-disable URL
params, hub vs dev graphistry), Protocol v2 quick reference, key file
map, current state, next goals (connection debug + style polish).

Architecture-level context stays in graphistry/ai_code_notes/
architecture/; this doc is agent-targeted operational layer only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SubscriptionManager fixes that surfaced once the example ran against a
fresh-loading iframe:

- init-replay: subscribes posted before the iframe's LocalDataSink
  listener was constructed silently dropped. On first init from the
  iframe, replay all acquired paths once so cold-load subscribe works.
- Per-path JSON dedupe in _onMessage: iframe's v1 push pipeline fires on
  every hover/animation tick with fresh object identities. Shallow eq
  treats these as updates and re-renders React each time. Dedupe at the
  boundary kills the selection-list flash.
- register() accepts an options object, sent alongside subscribe
  messages. First use: withColumns:true for .selection.labels so v1
  hand-enriched selections carry full column data for single-row
  inspector.
- refresh(path) method: cycles unsubscribe/subscribe and clears the
  serialize cache so post-mutation re-primes bypass dedupe.
- graphistry-iframe-log type relay: parent receives iframe-side
  diagnostic logs and forwards to host console, letting an agent owning
  the Vite terminal see inside-iframe behavior without devtools.

rpc.js gains request/response tracing with bounded-length preview so
call/get/set round-trips are visible in the same stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expose more of the Protocol v2 surface as declarative / imperative React
APIs so the example doesn't have to hand-roll falcor paths.

- <GraphistryProvider bg={hex}>: applied after the init handshake via
  rpc.set against scene.renderer.background.color (the live path —
  scene.bg.color is a $ref to the whole background subtree, writing
  there does not propagate). Gated on subscriptionAPIVersion !== null
  so we don't post before the iframe's listener exists.
- removeFilter(id) on the handle and useFilters().remove(id): calls the
  new filters.remove route exposed in ClientAPIRoutes.
- refreshAfter(promise, path): all mutating RPCs (addFilter,
  removeFilter, resetFilters, setSelectionExternal) now cycle the
  relevant subscription so panels reflect the post-mutation state —
  the iframe-side falcor-update emitter doesn't automatically observe
  RPC-AppRouter mutations.
- Selection registers with { withColumns: true } option so single-row
  inspector gets the full column map, not just title/globalIndex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ollapsible filters

Second round of UX after end-to-end data started flowing:

- Drops top-left logo, top-right connection badge, host/dataset footer
  per user feedback — cleaner overlay over the viz.
- Chrome flags: showInspector=false, showHistograms=false hide the
  iframe's built-in right-side panels so only React overlays are
  visible.
- Filters:
  - AST-aware pill rendering. query arrives as {ast, error}; walk the
    common node types (LimitExpression, BinaryPredicate, Literal,
    Identifier, MemberExpression, NotExpression, FunctionCall) into
    readable text. Unknown shapes fall back to JSON.
  - Click "×" to remove — wired to useFilters().remove(id).
  - Collapsible. Pinned to the bottom of the right rail as a strip
    labeled "Filters · N active"; expands on click.
- Selection inspector:
  - Hidden entirely when !ready or nothing selected.
  - Single-selection shows a glyph (filled dot for point, arrow for
    edge) + the row's title/label in the title bar; no static
    "Selection" heading.
  - Columns normalized from the pseudo-array {0:{key,value,dataType},…}
    shape and rendered with smart inline/stack: short key+value
    stays on one row right-aligned, long values drop to their own
    line. No dataType badge per feedback.
  - ScrollFade wrapper with mask-image gradient at top/bottom of long
    scroll regions; Card exposes bodyPad={false} so scroll meets card
    edges while padding lives inside the scroll content.
- Right rail outer wrapper is pointer-events-none; each card opts in
  via [&>*]:pointer-events-auto so empty gaps pass clicks through to
  the iframe.
- GraphistryProvider bg prop drives scene background to match the
  dashboard dark palette.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vite-plugin-console-forward API changed options between the two
versions we tried. Standardize on the current shape:

- captureErrors / captureRejections explicit (was unhandledErrors)
- prefix: 'browser' keeps forwarded lines grepable in vite stdout

Dev dep pin adjusted to match what npm resolved locally, plus lock
files in sibling projects brought up to date from npm install runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hand-off note for the deploy agent. Iframe's webpack-dev-server HMR
client was trying wss://exrhizome.tailc68bf0.ts.net:3000/sockjs-node
which isn't exposed via Tailscale — every viz-source edit required a
hard refresh. Ask: allocate a new proxied port (proposed :8495), set
WDS_SOCKET_HOST + WDS_SOCKET_PORT on the streamgl-viz service, and
confirm the wss upgrade lands on reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts reusable UI primitives out of App.tsx and moves the theme into
CSS custom properties driven by Tailwind 4's @theme. The example's job
becomes declarative composition: pick hooks, drop them into Panels,
compose the layout — no more one-off Tailwind classes sprinkled through
business logic.

src/ui.tsx (new) —
  - Panel (with optional collapsible header, titleNode, bodyPad)
  - ScrollArea (mask-image fade top/bottom — no extra DOM)
  - Button (primary / ghost / iconGhost)
  - Input (Enter-submit)
  - KeyValueRow (smart inline ↔ stacked based on combined length, row striping via altIndex)
  - StatusDot, PointGlyph, EdgeGlyph, ErrorBox, Chip

src/index.css —
  - `ink / surface / elevated / hover / border / border-strong` — desaturated
    midnight-purple surface stack. `ink` carries a touch more purple so the
    viz bg blends with the panels.
  - `accent / point / edge` — teal + purple at ~70% saturation. Semantic
    ok / warn / err pulled off their neon defaults.
  - `text / text-dim / text-faint` — three off-white steps with a warm
    purple cast.

src/App.tsx —
  - Dropped from ~440 LOC to ~280. Every panel is now a Panel + rows from
    the primitives; no hand-written borders / shadows / backdrops / radii
    outside ui.tsx.
  - Radii: rounded-xl/2xl → rounded / rounded-sm across the board.
  - SCENE_BG_HEX tracks the `ink` token so the RPC-set viz bg matches
    the dashboard surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@exrhizo exrhizo changed the title feat(external-bridge): React provider + hooks over Protocol v2 — client-api-context + example Client API Context -- Hook based Graphistry embed Apr 20, 2026
exrhizo and others added 2 commits April 20, 2026 02:00
The deploy ask already landed (webpack-dev-server reachable via :8495
Tailscale). Docs fit better outside this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
node:16.13.0-slim pinned to Debian buster, which hit EOL in Jun 2024.
http://deb.debian.org/debian buster Release now 404s, breaking the
apt-get update in the base stage and failing the Storybook workflow on
every build triggered by projects/client-api/** changes.

Switch to node:16-bullseye-slim — still node 16 (matches the lock files
across projects), but on Debian 11 bullseye which is supported until
mid-2026.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@exrhizo exrhizo marked this pull request as draft April 20, 2026 05:11
Falcor Bridge hooks package (`@graphistry/client-api-context`):
  - useColumns / usePalettes / useEncoding / columnsForEncoding, plus
    ColumnMeta / Palette / EncodingKind types.
  - readJsonGraphLeaf now follows `$ref` chains (depth-bounded), so every
    view-scoped leaf resolves through `workbooks.open → workbooksById[X]`
    and `views.current → viewsById[X]` without bailing at the first ref.
  - `refreshAfter` for filters now primes the cache via rpc.get before
    re-subscribing so a newly-added filter's expression data lands before
    the push, instead of leaving an unresolved-ref ghost row.
  - projectFilters drops phantom entries that lack id / query / name so
    intermediate cache pushes can't wipe the filter list to empty.

Example app:
  - App.tsx collapses to 23 lines — Provider + Scene + SettingsDrawer +
    SelectionInspector + FilterBar, each self-positioning.
  - Domain logic (polling, attribute bare-name, palette colors, atom
    envelope) moved out of the example and into the context package.
  - UI split into panels/ (Arrange, Layout, Encodings, Appearance,
    SelectionInspector, FilterBar, SettingsDrawer) and components/
    (PaletteSelect with inline swatch preview + PaletteSwatch +
    ChevronGlyph). Config lifted to src/config.ts.
  - Arrange button is full-width in the Drawer toolbar with an animated
    conic-gradient border (@Property --arrange-angle) when running.

deploy.md: docs how the agent-driven debug loop uses Vite stdout +
ScheduleWakeup to iterate without user interaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant