Skip to content

feat(standalone): support multiple tabs via leader election - #10914

Merged
eliandoran merged 2 commits into
mainfrom
feat/standalone-multi-tab-sharedworker
Aug 7, 2026
Merged

feat(standalone): support multiple tabs via leader election#10914
eliandoran merged 2 commits into
mainfrom
feat/standalone-multi-tab-sharedworker

Conversation

@eliandoran

Copy link
Copy Markdown
Contributor

The problem

Standalone keeps its database in the browser on the OPFS SAHPool VFS, whose sync access handles are held exclusively by one dedicated worker per origin. But every tab spawned its own worker (local-bridge.ts, from main.ts), so with two tabs open:

  1. Tab 2's installSahPool() threw NoModificationAllowedError — tab 1 holds the handles.
  2. local-server-worker.ts caught it and logged it as "SAHPool VFS not available", conflating "another tab has it" with "this browser has no OPFS".
  3. It therefore fell back to loadFromMemory()a fresh, empty database.
  4. sw.ts routed API traffic to matchAll()[0], so once tab 2 was focused both tabs could end up on that empty database.

So it wasn't "the second tab fails" — the second tab silently came up blank, hijacked routing, and anything written there evaporated on close.

The fix

A Web Lock elects a single leader tab (leader_election.ts). It alone starts a worker; the others proxy through the service worker, and the leader relays entity changes back over a BroadcastChannel so all tabs stay in sync. When the leader closes, the browser releases the lock and a queued tab is promoted — no heartbeat or timeout to get wrong.

The service worker caches which client is the leader, probes for it when unknown (it is evicted when idle), and retries elsewhere if a tab answers NOT_LEADER. Followers refuse to start a worker even if asked, so a stale route can never open a second database.

⚠️ SharedWorker does not work here — please don't re-attempt

The obvious design is a SharedWorker owning the database. It cannot work, proven against Chrome 151:

// inside a SharedWorker
{"hasNavStorage":true,"hasGetDirectory":true,"gotRoot":true,
 "hasCreateSAH":false,
 "error":"TypeError: fh.createSyncAccessHandle is not a function"}

createSyncAccessHandle is exposed only in DedicatedWorkerGlobalScope, and the SAHPool VFS is built entirely on it. The obvious escape hatch — have the SharedWorker nest a dedicated worker — also fails:

{"nestedWorker":false,"error":"ReferenceError: Worker is not defined"}

Note that SharedWorker itself is well supported (caniuse/caniwebview: iOS WKWebView 16.5+, Android WebView 148+). The blocker is the OPFS API it would need, which no support table covers. This was only caught by driving a real browser — the full unit suite passed against mocked ports.

Also included

db_lock.ts serialises async routes against the single SQLite connection. createAsyncRoute holds a transaction open across awaits, so a second async route would BEGIN IMMEDIATE inside it, and a synchronous route would be folded in as a SAVEPOINT (see the nesting branch at sql_provider.ts:546) — meaning a failed import could roll back an unrelated request's writes. Latent before (one tab already issues concurrent requests), much likelier with several. Shared work keeps a synchronous fast path and only becomes async while an exclusive holder is active.

Testing

  • 4 new Playwright multi-tab tests, passing in a real browser with no retries: second tab opens the real DB; both stay usable; a write from the follower reaches the leader's tree (exercising both the proxy and the broadcast); closing the leader promotes the survivor.
  • New unit tests for leader election, the DB lock, leadership gating in the bridge, and service-worker leader routing/probing/NOT_LEADER retry.
  • Full standalone suite: 231 files, 3452 tests green. Repo typecheck and lint clean. Shared E2E suite (40 tests) passes as a regression check.

Notes for review

  • componentId was investigated and needs no change: server.ts:187 overrides the header per call and component ids are already ${className}-${randomString(8)} (component.ts:27), so they're unique per tab. glob.componentId: "" is only an unused fallback.
  • Capacitor is unaffected: iOS uses the in-page interceptors (no service worker), and both mobile platforms are single-window, so leadership is trivially satisfied. Web Locks missing ⇒ assume sole tab.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🖥️ App preview is ready!

🔗 Preview URL: https://pr-10914.trilium-app.pages.dev
📖 Production URL: https://app.triliumnotes.org

✅ All checks passed

This preview will be updated automatically with new commits.

@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR enables multi-tab standalone operation by electing one database-owning tab and routing follower requests through it. Major changes:

  • Adds Web Lock-based leader election and service-worker leader discovery.
  • Relays entity-change messages between tabs using BroadcastChannel.
  • Serializes asynchronous database routes against the shared SQLite connection.
  • Adds unit and Playwright coverage for routing, failover, and multi-tab behavior.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/standalone/src/leader_election.ts Elects one tab with an exclusive Web Lock and holds ownership for the tab lifetime.
apps/standalone/src/sw.ts Discovers and caches the leader, preserves request bodies across retries, and bounds stale-leader recovery.
apps/standalone/src/local-bridge.ts Gates worker ownership on leadership and relays entity-change messages across tabs.
apps/standalone/src/lightweight/db_lock.ts Serializes exclusive asynchronous route work while retaining a synchronous shared fast path.
apps/standalone/e2e/multi_tab.spec.ts Exercises shared database access, follower writes, cross-tab updates, and leader failover.

Sequence Diagram

sequenceDiagram
    participant F as Follower tab
    participant SW as Service worker
    participant L as Leader tab
    participant DB as Dedicated DB worker
    F->>SW: API request
    SW->>L: LOCAL_FETCH
    L->>DB: Forward request
    DB-->>L: Response and entity changes
    L-->>SW: LOCAL_FETCH_RESPONSE
    SW-->>F: API response
    L-->>F: Broadcast entity changes
Loading

Reviews (3): Last reviewed commit: "fix(standalone): re-send request body wh..." | Re-trigger Greptile

Comment thread apps/standalone/src/lightweight/db_lock.ts
Comment thread apps/standalone/src/lightweight/browser_routes.ts
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 1.89kB (0.0%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
standalone-esm 53.28MB 1.89kB (0.0%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: client-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
src/src-*.js 65 bytes 209 bytes 45.14% ⚠️
src/src-*.js -65 bytes 144 bytes -31.1%
src/dist-*.js 26 bytes 82 bytes 46.43% ⚠️
src/dist-*.js -7 bytes 56 bytes -11.11%
src/dist-*.js -19 bytes 63 bytes -23.17%
view changes for bundle: standalone-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
src/main-*.js 32 bytes 10.06kB 0.32%
assets/browser_routes-*.js 408 bytes 8.33kB 5.15% ⚠️
src/local-*.js 907 bytes 3.98kB 29.52% ⚠️
sw.js 546 bytes 2.47kB 28.35% ⚠️

Files in src/main-*.js:

  • ./src/main.ts → Total Size: 3.37kB

Files in src/local-*.js:

  • ./src/leader_election.ts → Total Size: 520 bytes

  • ./src/local-bridge.ts → Total Size: 5.97kB

Files in sw.js:

  • ./src/sw.ts → Total Size: 4.37kB

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 3 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
apps/standalone/src/sw.ts 93.54% 0 Missing and 2 partials ⚠️
apps/standalone/src/local-bridge.ts 96.00% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

eliandoran and others added 2 commits August 7, 2026 14:28
Standalone keeps its database in the browser on the OPFS SAHPool VFS,
whose sync access handles are exclusive to one dedicated worker per
origin. Every tab spawned its own worker, so a second tab could not open
the database at all: installSahPool() threw, the error was swallowed as
"SAHPool VFS not available", and the tab silently fell back to an empty
in-memory database. Because the service worker routed API traffic to an
arbitrary window, that empty database could capture the first tab too.

A Web Lock now elects a single leader tab. It alone starts a worker; the
other tabs proxy their API calls to it through the service worker, and
the leader relays entity changes back over a BroadcastChannel so every
tab stays in sync. When the leader closes, the browser releases the lock
and a waiting tab is promoted with no heartbeat or timeout involved.

The service worker tracks which client is the leader, probes for it when
that is unknown (it is evicted when idle), and retries elsewhere if a tab
answers NOT_LEADER. Followers never start a worker even if asked.

Also serialises async routes against the single SQLite connection.
createAsyncRoute holds a transaction open across awaits, so a second
async route would BEGIN inside it and a synchronous route would be folded
in as a SAVEPOINT — meaning a failed import could roll back an unrelated
request's writes. Latent before, but far likelier with several tabs.

Co-Authored-By: Claude <noreply@anthropic.com>
The service worker read the request body directly, so the NOT_LEADER
retry path forwarded an already-consumed Request and any body-bearing
mutation failed with "Body has already been read" instead of reaching
the current leader. Read from a clone so the original stays intact.

Also bound the retry to a single attempt: leadership can flip between a
tab answering WHO_IS_LEADER and that same tab serving the fetch, which
could otherwise ping-pong between two tabs indefinitely.

The spec's mock Request permitted unlimited body reads, which is why a
POST test would have passed regardless; it now models real consumption
semantics so both paths are actually covered.

Co-Authored-By: Claude <noreply@anthropic.com>
@eliandoran
eliandoran force-pushed the feat/standalone-multi-tab-sharedworker branch from befcb94 to d3268eb Compare August 7, 2026 13:59
@eliandoran
eliandoran marked this pull request as ready for review August 7, 2026 14:29
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Aug 7, 2026
@eliandoran eliandoran added this to the v0.105.0 milestone Aug 7, 2026
@eliandoran
eliandoran merged commit 5e63c96 into main Aug 7, 2026
18 checks passed
@eliandoran
eliandoran deleted the feat/standalone-multi-tab-sharedworker branch August 7, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant