feat(joint-router-avoid): add routeAllSync() and routeSubgraphSync() for the main-thread provider - #3491
Open
kumilingus wants to merge 6 commits into
Open
Conversation
kumilingus
force-pushed
the
feat/router-avoid-eager-main-thread
branch
from
August 26, 2026 21:08
54e99dd to
a6535a3
Compare
kumilingus
force-pushed
the
feat/router-avoid-eager-main-thread
branch
2 times, most recently
from
August 26, 2026 21:21
6a3bb81 to
6f388d4
Compare
kumilingus
marked this pull request as draft
August 26, 2026 21:24
kumilingus
force-pushed
the
feat/router-avoid-eager-main-thread
branch
from
August 27, 2026 14:02
6f388d4 to
d0125e0
Compare
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
force-pushed
the
feat/router-avoid-eager-main-thread
branch
from
August 27, 2026 14:13
d0125e0 to
500cc7f
Compare
- 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>
There was a problem hiding this comment.
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.isSynchronousplusrouteAllSync()/routeSubgraphSync()(main-thread only) and exports the newRoutedLinktype. - Extends
routeAll()/routeSubgraph()to resolve with a discriminatedRoutingResultincludingroutedLinkson'done'. - Hardens
MainThreadProvideragainst WASM aborts caused by JS exceptions escaping throughprocessTransaction(), 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.
…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
marked this pull request as ready for review
August 28, 2026 13:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 andasynckeywords. 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.RoutingResultis now a discriminated union:{ status: 'done', routedLinks: RoutedLink[] }|{ status: 'cancelled' }— the'done'case lists the routed links withorigin/reason, so both one-shot APIs report the same thing (one as a value, one on the promise).routeAllSync()/routeSubgraphSync()— new, returnRoutedLink[], 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 originatingcell.set()in started mode. There is nothing to return: the pass either completed or threw —RoutingResult's'cancelled'only exists sodestroy()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:worker: true) — "userouteAll()instead";routeAll()/routeSubgraph()pass is still in flight — running now would have the queued pass silently override the result a microtask later;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; firinglink:routingfor a state that never exists, oridlefrom inside a call that is about to return, would be noise at best and a spurious trigger for an app usingidleas its async "settled" signal at worst. Same stance asfs.readFileSyncvs. stream events: synchronous APIs return and throw, they sit outside the event model. Whatlink:routedwould have reported is returned instead:RoutedLink[]—{ link, origin, reason? }per link whose route was applied, in application order; links claimed viainterceptUnroutableLinkare not included (nothing was applied to them). New exported typeRoutedLink.RouterService.isSynchronous— new getter,truewith the main-thread provider. Portable code checks it before reaching for the sync methods.Under the hood
MainThreadProvider.syncandRouterService.syncare no longerasync(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 throughperformRoute'sawait. Side effect worth knowing: with the main-thread provider, an error duringstart()'s initial sync now throws out ofstart()instead of surfacing as an unhandled rejection — consistent with how started-mode incremental errors already behave.Provider.isSynchronousis the capability flag driving all of this (trueforMainThreadProvider,falseforWorkerProvider).Tests
TDD — written first and watched fail:
routeAllSync() applies the routes and returns the result during the callrouteSubgraphSync() routes exactly the given cells during the callrouteAll() stays asynchronous: routes land only once the promise resolves— pins the unchanged contracta synchronous pass refuses to run while an asynchronous one is in flight— and is allowed again once it settlesa throwing consumer callback escapes routeAllSync() as a synchronous throw/the same consumer error rejects the asynchronous routeAll()isSynchronous reports true for the main-thread providerThe 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-avoidminor ×2 (sync methods,isSynchronous).Notes
start(), againstmaster): unchanged async passes keep that supersession check; a sync pass completes beforestart()could be called.standalone-link-routing.mdx, docs repo) should gain a section on the sync variants once this lands.🤖 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:
processTransaction(); a JS exception fromsetRouteAttributes(or achange:*listener) unwinding through those WASM frames aborts the module for the rest of the page —"program has already aborted!"on every later call.MainThreadProvidernow holds the first such error and rethrows it onceprocessTransaction()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 ofstart()and leaves the router stopped (documented@throws) instead of started over a partially populated engine. The graphresetpath reports a synchronous failure as a rejection instead of throwing from inside the graph's event dispatch, so laterresetlisteners still run.destroy().routeLink()ignores routes arriving afterdestroy()(the main-threaddestroy()cannot stop a transaction in progress); the misleading destroy-mid-pass swallow inrouteSync()is gone.isSynchronous,sync()opens the routing cycle without writing an interim fallback route — the avoid route lands in the same call, so the interim write (arightAnglecomputation, alink.set()and a callback invocation per link) could never be observed. Applies tostart()/routeAll()on the main thread too;setRouteAttributesconsumers see one'avoid'write instead of'fallback'then'avoid'. Test pins the single write.isSynchronousgetter moved out from betweenisStartedand its JSDoc; README API section,init.mtsJSDoc and comment cross-references updated.'avoid'write (exercising the provider path — the previous version threw in the pre-provider fallback phase and passed withasyncrestored); all documented throws covered (started, destroyed, in-flight for both methods); fixturesdestroy(); 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-avoidPRsVerified 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
isStartedsupersession check sits at the top ofperformRoute(), before this PR's routed-link collection starts; #3489'sidleafter incremental changes is untouched by thequietgating (which only spans a synchronous pass); #3488 is disjoint.