Skip to content

feat(joint-router-avoid): add routeAllSync() and routeSubgraphSync() for the main-thread provider - #3491

Open
kumilingus wants to merge 6 commits into
clientIO:devfrom
kumilingus:feat/router-avoid-eager-main-thread
Open

feat(joint-router-avoid): add routeAllSync() and routeSubgraphSync() for the main-thread provider#3491
kumilingus wants to merge 6 commits into
clientIO:devfrom
kumilingus:feat/router-avoid-eager-main-thread

Conversation

@kumilingus

@kumilingus kumilingus commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Description

With the main-thread provider a routing pass is synchronous WASM work end to end (raw libavoid is fully synchronous); the only asynchrony in routeAll() / routeSubgraph() is the wrapper's own pass queue and async keywords. That forced every consumer into an async context even when nothing asynchronous happens — synchronous export, headless pipelines, fixtures and tests all needed promise plumbing.

Change (option C of the sync-routing analysis)

  • routeAll() / routeSubgraph() — same contract, richer result. Queued, asynchronous, errors reject the promise, identical timing on every provider. RoutingResult is now a discriminated union: { status: 'done', routedLinks: RoutedLink[] } | { status: 'cancelled' } — the 'done' case lists the routed links with origin/reason, so both one-shot APIs report the same thing (one as a value, one on the promise).

  • routeAllSync() / routeSubgraphSync() — new, return RoutedLink[], emit no events. Every route is on its link when they return; errors (a throwing consumer callback, a WASM abort) throw out of the call, the same way a failing callback throws out of the originating cell.set() in started mode. There is nothing to return: the pass either completed or threw — RoutingResult's 'cancelled' only exists so destroy() can interrupt a queued async pass without rejecting, which cannot happen to a pass that runs inside the call. Loud failures instead of silent ordering differences:

    • throw when a Worker provider is in use (worker: true) — "use routeAll() instead";
    • throw when a routeAll()/routeSubgraph() pass is still in flight — running now would have the queued pass silently override the result a microtask later;
    • throw when called after destroy().

    They emit none of the routing events (link:routing, link:routed, link:routing:cancelled, idle). Those describe an asynchronous gap — a link whose route is pending, the moment nothing is pending any more — and a synchronous pass has no such gap; firing link:routing for a state that never exists, or idle from inside a call that is about to return, would be noise at best and a spurious trigger for an app using idle as its async "settled" signal at worst. Same stance as fs.readFileSync vs. stream events: synchronous APIs return and throw, they sit outside the event model. What link:routed would have reported is returned instead: RoutedLink[]{ link, origin, reason? } per link whose route was applied, in application order; links claimed via interceptUnroutableLink are not included (nothing was applied to them). New exported type RoutedLink.

  • RouterService.isSynchronous — new getter, true with the main-thread provider. Portable code checks it before reaching for the sync methods.

Under the hood MainThreadProvider.sync and RouterService.sync are no longer async (they never awaited anything) so errors on the synchronous path escape the calling frame instead of being laundered into rejections; the promise-returning API still sees them as rejections through performRoute's await. Side effect worth knowing: with the main-thread provider, an error during start()'s initial sync now throws out of start() instead of surfacing as an unhandled rejection — consistent with how started-mode incremental errors already behave.

Provider.isSynchronous is the capability flag driving all of this (true for MainThreadProvider, false for WorkerProvider).

Tests

TDD — written first and watched fail:

  • routeAllSync() applies the routes and returns the result during the call
  • routeSubgraphSync() routes exactly the given cells during the call
  • routeAll() stays asynchronous: routes land only once the promise resolves — pins the unchanged contract
  • a synchronous pass refuses to run while an asynchronous one is in flight — and is allowed again once it settles
  • a throwing consumer callback escapes routeAllSync() as a synchronous throw / the same consumer error rejects the asynchronous routeAll()
  • isSynchronous reports true for the main-thread provider

The Worker-provider throw is not exercised in the karma suite (no Worker script can be spawned there); it is a one-line guard on provider.isSynchronous.

35/35 passing; lint clean. Changesets: @joint/router-avoid minor ×2 (sync methods, isSynchronous).

Notes

🤖 Generated with Claude Code

Review follow-up (second commit)

An audit of this PR surfaced one real, pre-existing crash and several smaller issues — all addressed:

  • WASM abort on a throwing consumer callback (pre-existing, every mode). avoid invokes the connector callbacks from inside processTransaction(); a JS exception from setRouteAttributes (or a change:* listener) unwinding through those WASM frames aborts the module for the rest of the page — "program has already aborted!" on every later call. MainThreadProvider now holds the first such error and rethrows it once processTransaction() has returned. Test: a throwing callback during a started-mode move surfaces the error and the engine keeps routing.
  • start() failure semantics. A synchronous initial-sync failure now throws out of start() and leaves the router stopped (documented @throws) instead of started over a partially populated engine. The graph reset path reports a synchronous failure as a rejection instead of throwing from inside the graph's event dispatch, so later reset listeners still run.
  • No routes after destroy(). routeLink() ignores routes arriving after destroy() (the main-thread destroy() cannot stop a transaction in progress); the misleading destroy-mid-pass swallow in routeSync() is gone.
  • Single write per link on the synchronous path. With isSynchronous, sync() opens the routing cycle without writing an interim fallback route — the avoid route lands in the same call, so the interim write (a rightAngle computation, a link.set() and a callback invocation per link) could never be observed. Applies to start()/routeAll() on the main thread too; setRouteAttributes consumers see one 'avoid' write instead of 'fallback' then 'avoid'. Test pins the single write.
  • Docs: isSynchronous getter moved out from between isStarted and its JSDoc; README API section, init.mts JSDoc and comment cross-references updated.
  • Tests: the sync-error test now throws on the 'avoid' write (exercising the provider path — the previous version threw in the pre-provider fallback phase and passed with async restored); all documented throws covered (started, destroyed, in-flight for both methods); fixtures destroy(); API-shape sanity test extended. 40/40.

Two more patch changesets (WASM abort, single write). Changeset description lengths left as they are by maintainer decision.

Compatibility with the other open router-avoid PRs

Verified by cherry-picking #3489, #3490 and #3488 onto this branch and running the combined suite: 46/46, lint clean. Two mechanical conflicts to expect when they meet (both this PR's fault, since it touches the same spots):

  • MainThreadProvider.processTransaction()fix(joint-router-avoid): fire idle after incremental main-thread changes #3489 (idle parity) and this PR each introduce a helper of that name. Fold into one body: avoidRouter.processTransaction(); trigger('processed'); rethrow the deferred callback error. Both call-site rewrites are identical.
  • test/index.js — every PR appends modules at the end of the file; keep all of them (order irrelevant).

Semantically: #3490's isStarted supersession check sits at the top of performRoute(), before this PR's routed-link collection starts; #3489's idle after incremental changes is untouched by the quiet gating (which only spans a synchronous pass); #3488 is disjoint.

@kumilingus
kumilingus force-pushed the feat/router-avoid-eager-main-thread branch from 54e99dd to a6535a3 Compare August 26, 2026 21:08
@kumilingus kumilingus changed the title feat(joint-router-avoid): run one-shot routing passes during the call when idle feat(joint-router-avoid): run one-shot routing passes during the call with a synchronous provider Aug 26, 2026
@kumilingus
kumilingus force-pushed the feat/router-avoid-eager-main-thread branch 2 times, most recently from 6a3bb81 to 6f388d4 Compare August 26, 2026 21:21
@kumilingus
kumilingus marked this pull request as draft August 26, 2026 21:24
@kumilingus
kumilingus force-pushed the feat/router-avoid-eager-main-thread branch from 6f388d4 to d0125e0 Compare August 27, 2026 14:02
@kumilingus kumilingus changed the title feat(joint-router-avoid): run one-shot routing passes during the call with a synchronous provider feat(joint-router-avoid): add routeAllSync() and routeSubgraphSync() for the main-thread provider Aug 27, 2026
routeAll()/routeSubgraph() keep their uniform asynchronous contract on
every provider. The new synchronous variants return the RoutingResult
directly for the main-thread provider - every route is on its link when
they return and errors throw out of the call, matching started-mode
behaviour - and fail loudly otherwise: they throw when a Worker
provider is in use or when an asynchronous pass is still in flight
(running then would have the queued pass silently override the result
a microtask later). RouterService.isSynchronous exposes the provider
capability so portable code can branch before calling them.

MainThreadProvider.sync and RouterService.sync are no longer async
(they never awaited anything), so errors on the synchronous path
escape the calling frame instead of being laundered into rejections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kumilingus
kumilingus force-pushed the feat/router-avoid-eager-main-thread branch from d0125e0 to 500cc7f Compare August 27, 2026 14:13
kumilingus and others added 4 commits August 27, 2026 21:53
- A consumer callback throwing while avoid invokes connector callbacks
  from inside processTransaction() aborted the WASM module for good (a JS
  exception unwinding through WASM frames). MainThreadProvider now holds
  the first such error and rethrows it once the WASM call has returned.
  Pre-existing in every mode; surfaced by testing the avoid-route path.
- start(): a synchronous failure of the initial sync leaves the router
  stopped instead of started over a partially populated engine, and is
  documented. The graph 'reset' path reports synchronous failures as a
  rejection instead, so the graph's later reset listeners still run.
- routeLink() ignores routes arriving after destroy(); the wrong
  destroy-mid-pass swallow in routeSync() is gone.
- With a synchronous provider, sync() opens the routing cycle without
  writing an interim fallback route - the avoid route lands in the same
  call, so the interim write could never be observed.
- isSynchronous getter no longer sits between isStarted and its JSDoc;
  README, init JSDoc and comment cross-references updated; tests cover
  the documented throws, clean up their instances and extend the API
  shape check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The routing-cycle events (link:routing, link:routed,
link:routing:cancelled, idle) describe an asynchronous gap - a link
whose route is pending, the moment nothing is pending any more. A
synchronous pass has no such gap: the call returning is the completion
signal, a throw the failure signal. Like fs.readFileSync() and the
stream events, routeAllSync()/routeSubgraphSync() sit outside the event
model; setRouteAttributes/interceptUnroutableLink remain the per-link
hooks for synchronous code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
routeAllSync()/routeSubgraphSync() return RoutedLink[] - the link:routed
payload (link, origin, reason) as a value, one entry per link whose
route was applied, in application order. Links claimed via
interceptUnroutableLink are not included, as nothing was applied to
them. Sync APIs hand results back as values; this is what the dropped
events would have reported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… routed links

RoutingResult becomes a discriminated union: a 'done' result carries
routedLinks (RoutedLink[]) - the same information the link:routed
events carry, one entry per link whose route was applied, in
application order - while 'cancelled' carries nothing. Both one-shot
APIs now report the same thing, one as a value, one on the promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a synchronous one-shot routing API for @joint/router-avoid when using the main-thread provider, while keeping the existing promise-based API provider-agnostic and now returning richer per-link routing results. This fits the package’s role as a routing service wrapper over libavoid (WASM main-thread vs Worker providers) by letting consumers opt into sync behavior only when the provider can actually guarantee it.

Changes:

  • Introduces RouterService.isSynchronous plus routeAllSync() / routeSubgraphSync() (main-thread only) and exports the new RoutedLink type.
  • Extends routeAll() / routeSubgraph() to resolve with a discriminated RoutingResult including routedLinks on 'done'.
  • Hardens MainThreadProvider against WASM aborts caused by JS exceptions escaping through processTransaction(), and adds extensive QUnit coverage + docs/changesets.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/joint-router-avoid/src/RouterService.mts Adds sync APIs, isSynchronous, routed-link collection, updated start/sync semantics, and richer one-shot results.
packages/joint-router-avoid/src/providers/Provider.mts Adds the isSynchronous capability flag to the provider contract.
packages/joint-router-avoid/src/providers/MainThreadProvider.mts Declares synchronous capability and defers/rethrows callback errors post-processTransaction() to avoid WASM abort.
packages/joint-router-avoid/src/providers/WorkerProvider.mts Declares isSynchronous = false for the Worker provider.
packages/joint-router-avoid/src/init.mts Updates init docs to mention sync one-shot methods when main-thread provider is used.
packages/joint-router-avoid/src/index.mts Exports the new RoutedLink type.
packages/joint-router-avoid/README.md Documents new sync APIs, isSynchronous, and the richer async RoutingResult.
packages/joint-router-avoid/test/index.js Adds/extends tests for sync methods, result shape, in-flight guards, error behavior, and main-thread abort hardening.
.changeset/plain-routes-sync.md Minor changeset for sync one-shot APIs.
.changeset/loud-guards-assert.md Minor changeset for isSynchronous.
.changeset/full-results-list.md Minor changeset for RoutingResult carrying routedLinks.
.changeset/steady-engines-survive.md Patch changeset for main-thread WASM-abort prevention behavior.
.changeset/lean-passes-write.md Patch changeset for eliminating interim fallback write on synchronous path.
Suppressed comments (1)

packages/joint-router-avoid/src/RouterService.mts:580

  • In routeSync(), the in-flight-pass guard is checked before the destroyed guard. If destroy() was called while an async pass is still settling, calling routeAllSync()/routeSubgraphSync() will throw "still in flight" instead of the documented "destroyed" error, which is misleading for callers.
        if (this.pendingPasses > 0) {
            throw new Error('A routeAll()/routeSubgraph() pass is still in flight. Await it before routing synchronously.');
        }
        if (this.destroyed) {
            throw new Error('RouterService has been destroyed.');
        }

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/joint-router-avoid/src/RouterService.mts Outdated
Comment thread packages/joint-router-avoid/test/index.js Outdated
…outing API

routeSync() JSDoc and a test title still said the synchronous passes
return nothing; they return the routed links. The destroyed guard now
precedes the in-flight guard, so a synchronous pass requested after
destroy() reports the documented 'destroyed' error rather than 'still
in flight' when an asynchronous pass was still settling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kumilingus
kumilingus marked this pull request as ready for review August 28, 2026 13:50
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.

2 participants