diff --git a/.changeset/dynamic-handle-remapping.md b/.changeset/dynamic-handle-remapping.md new file mode 100644 index 00000000..020d5f4b --- /dev/null +++ b/.changeset/dynamic-handle-remapping.md @@ -0,0 +1,26 @@ +--- +"@inkandswitch/patchwork-providers": minor +"@inkandswitch/patchwork-elements": patch +--- + +Dynamic handle remapping without remounts: `repo:handle-descriptor` is now a +streaming subscription end-to-end. + +- `OverlayRepo.find`/`findWithProgress` keep their descriptor subscription + open instead of taking only the first answer. When a remapper emits a new + descriptor (e.g. a draft overlay re-pointing at a different clone), the live + `OverlayHandle` is re-pointed in place via the new `swapBackingDocHandle`: + forwarded event listeners are re-wired onto the new backing, cached + sub-handles are recursively re-based, and a synthetic `change` event with + `scopeReplaced: true` tells consumers to reconcile from `doc()` rather than + apply patches (the old and new backings may be divergent forks). One-shot + providers that answer exactly once keep working unchanged. +- `forwardingProxy` accepts a backing getter so the proxy identity consumers + hold stays fixed while the backing changes underneath. +- `OverlayRepo` gained `dispose()`, which tears down the live descriptor + subscriptions; `` calls it when the element disconnects + (the overlay repo survives attribute-driven teardowns/re-syncs). +- `OverlayHandle.emit` isolates listener errors (logged, not rethrown) so one + consumer that cannot handle an event does not starve the listeners behind + it, and skips listeners unsubscribed during the same emit so a torn-down + consumer is never invoked with an event it already handed off. diff --git a/core/elements/src/patchwork-view.ts b/core/elements/src/patchwork-view.ts index 5c0a828e..1e01ee09 100644 --- a/core/elements/src/patchwork-view.ts +++ b/core/elements/src/patchwork-view.ts @@ -216,7 +216,12 @@ export function registerPatchworkViewElement( } disconnectedCallback() { - void this.#teardown(); + // The overlay repo survives attribute-driven re-syncs; only a real + // disconnect releases it. Detach now so a re-connect gets a fresh + // shim, then dispose once the teardown has run. + const overlayRepo = this.#overlayRepo; + this.#overlayRepo = null; + void this.#teardown().then(() => overlayRepo?.dispose()); } connectedMoveCallback() { diff --git a/packages/providers/core/src/forwarding-proxy.ts b/packages/providers/core/src/forwarding-proxy.ts index 0065f8e1..6546dd38 100644 --- a/packages/providers/core/src/forwarding-proxy.ts +++ b/packages/providers/core/src/forwarding-proxy.ts @@ -3,26 +3,32 @@ * `overrides` itself and transparently forwards every other access to * `backing`. * + * `backing` may also be a getter, re-evaluated on every access, so the owner + * can swap the backing under a stable proxy identity (see + * `OverlayHandle.swapBackingDocHandle`). + * * Both sides are read with the matching receiver and functions are bound to * their owner: owned members run against `overrides` (so its private `#fields` - * keep working) and forwarded members run against `backing` (so the borrowed + * keep working) and forwarded members run against the backing (so the borrowed * method gets the right `this`). This lets the overlay classes spell out only * the handful of members whose behavior differs and inherit the rest of the * large, evolving `Repo` / `DocHandle` surface for free. */ export function forwardingProxy( overrides: object, - backing: object, + backing: object | (() => object), owned: ReadonlySet ): T { + const currentBacking = + typeof backing === "function" ? (backing as () => object) : () => backing; return new Proxy(overrides, { get(target, prop) { - const source = owned.has(prop) ? target : backing; + const source = owned.has(prop) ? target : currentBacking(); const value = Reflect.get(source, prop, source); return typeof value === "function" ? value.bind(source) : value; }, has(target, prop) { - return owned.has(prop) || prop in backing || prop in target; + return owned.has(prop) || prop in currentBacking() || prop in target; }, }) as T; } diff --git a/packages/providers/core/src/overlay-handle.ts b/packages/providers/core/src/overlay-handle.ts index 1cc4c39e..f523c075 100644 --- a/packages/providers/core/src/overlay-handle.ts +++ b/packages/providers/core/src/overlay-handle.ts @@ -11,8 +11,9 @@ import { forwardingProxy } from "./forwarding-proxy.js"; type Listener = (...args: unknown[]) => void; // The only members whose behavior an overlay handle changes: the presented -// identity, the identity-preserving `sub` (so sub-handle urls stay in the -// presented space), the argument-unwrapping handle comparisons +// identity, the backing accessors (`backingHandle`/`swapBackingDocHandle`), +// the identity-preserving `sub` (sub-handle urls stay in the presented space), +// the argument-unwrapping handle comparisons // (`merge`/`overlaps`/`contains`/`isChildOf`/`equals`), and the re-stamping // EventEmitter surface. Everything else (doc/change/heads/ref/view/diff/...) // forwards to the backing handle. @@ -20,6 +21,7 @@ const OVERLAY_HANDLE_OWNED: ReadonlySet = new Set([ "url", "documentId", "backingHandle", + "swapBackingDocHandle", "sub", "merge", "overlaps", @@ -41,25 +43,35 @@ export type OverlayHandleOpts = { backing: DocHandle; }; +type SubCacheEntry = { + wrapped: OverlayHandle; + /** Original `sub(...)` args, replayed on a backing swap. */ + segments: unknown[]; +}; + /** - * A url-hiding proxy around a fixed backing `DocHandle`. `url`/`documentId` + * A url-hiding proxy around a swappable backing `DocHandle`. `url`/`documentId` * always report the *presented* url; every other operation forwards to the - * backing handle. Event subscriptions are tracked locally and the backing - * handle's events are lazily forwarded with `payload.handle` re-stamped to this - * wrapper, so consumers never observe the backing (clone) handle or its url. + * current backing handle. Event subscriptions are tracked locally and the + * backing handle's events are lazily forwarded with `payload.handle` + * re-stamped to this wrapper, so consumers never observe the backing (clone) + * handle or its url. * - * Unlike the copy-on-write handle it is modelled on, the backing never swaps: - * remappers clone eagerly and hand back the clone up front, so there is no - * COW, no re-wiring, and no synthetic change nudge. + * Remappers may re-point a live handle at a different backing via + * {@link swapBackingDocHandle}: listeners are re-wired, cached sub-handles + * re-based, and a synthetic `change` with `scopeReplaced: true` tells + * consumers to re-read `doc()` — the backings may be divergent forks with no + * patch stream connecting them. */ export class OverlayHandle { readonly #originalUrl: AutomergeUrl; - readonly #handle: DocHandle; + #handle: DocHandle; readonly #listeners = new Map>(); - readonly #forwarded = new Map(); - // Sub-handle wrappers keyed by their backing (canonicalised) url, so repeated - // `sub(...)` of the same path return the same wrapper instance. - readonly #subCache = new Map>(); + // Forwarders attached to the current backing; moved over on a swap. + readonly #forwarders = new Map(); + // Sub-handle wrappers keyed by *presented* url (stable across swaps), so + // repeated `sub(...)` of the same path return the same wrapper. + readonly #subCache = new Map(); // The Proxy returned from the constructor — the object consumers actually // hold. Re-stamped onto forwarded events so identity stays consistent. #self: OverlayHandle; @@ -69,7 +81,7 @@ export class OverlayHandle { this.#handle = opts.backing; this.#self = forwardingProxy>( this, - opts.backing, + () => this.#handle, OVERLAY_HANDLE_OWNED ); return this.#self; @@ -83,11 +95,55 @@ export class OverlayHandle { return parseAutomergeUrl(this.#originalUrl).documentId; } - /** @internal The live handle this wrapper forwards to. */ + /** @internal The live handle this wrapper currently forwards to. */ get backingHandle(): DocHandle { return this.#handle; } + /** + * @internal Re-point this wrapper and its cached sub-handles at a new + * backing, keeping presented and proxy identity intact. Emits a synthetic + * `change` with `scopeReplaced: true` so consumers reconcile from `doc()` + * instead of applying patches. + */ + swapBackingDocHandle(next: DocHandle): void { + const previous = this.#handle; + if (next === previous) return; + const before = previous.doc(); + this.#handle = next; + + // Move the forwarders onto the new backing so listeners keep firing. + const emitter = (handle: DocHandle) => + handle as unknown as { + on(ev: string, fn: Listener): void; + off(ev: string, fn: Listener): void; + }; + for (const [ev, forwarder] of this.#forwarders) { + emitter(previous).off(ev, forwarder); + emitter(next).on(ev, forwarder); + } + + // Re-base cached sub-handles against the new backing; each child emits + // its own scopeReplaced change. + for (const entry of this.#subCache.values()) { + const nextSub = ( + next as unknown as { sub: (...s: unknown[]) => DocHandle } + ).sub(...entry.segments); + entry.wrapped.swapBackingDocHandle(nextSub); + } + + // No patch stream connects the two backings, so signal a wholesale scope + // replacement; consumers reconcile from `doc` instead of patches. + const after = next.doc(); + this.emit("change", { + handle: this.#self, + doc: after, + patches: [], + scopeReplaced: true, + patchInfo: { before, after, source: "change" }, + }); + } + // Handle comparisons read the *other* handle's internals (Automerge rejects // a foreign wrapper, and `overlaps`/`contains` touch its private `#path`), so // unwrap an overlay argument down to its backing before delegating. @@ -116,22 +172,23 @@ export class OverlayHandle { // would leak the backing (clone) documentId and break identity for consumers // that resolve it back through the overlay. We scope `sub` to the backing // handle (reads/writes still hit the clone) but wrap the result in another - // OverlayHandle whose presented url swaps the documentId back to ours. The - // backing sub-handle is canonicalised by the repo, so caching by its url - // keeps our wrappers referentially stable too. + // OverlayHandle whose presented url swaps the documentId back to ours. + // Cached by presented url (stable across swaps); the segments are kept so + // a swap can replay them against the new backing. sub(...segments: unknown[]): DocHandle { const backingSub = ( this.#handle as unknown as { sub: (...s: unknown[]) => DocHandle; } ).sub(...segments); - const cached = this.#subCache.get(backingSub.url); - if (cached) return cached as unknown as DocHandle; + const presentedSubUrl = restampUrl(this.#originalUrl, backingSub.url); + const cached = this.#subCache.get(presentedSubUrl); + if (cached) return cached.wrapped as unknown as DocHandle; const wrapped = new OverlayHandle({ - presentedUrl: restampUrl(this.#originalUrl, backingSub.url), + presentedUrl: presentedSubUrl, backing: backingSub, }); - this.#subCache.set(backingSub.url, wrapped); + this.#subCache.set(presentedSubUrl, { wrapped, segments }); return wrapped as unknown as DocHandle; } @@ -173,10 +230,26 @@ export class OverlayHandle { return this.#self; } + // Two deliberate departures from Node's EventEmitter: listener errors are + // logged rather than rethrown, so one failing consumer doesn't starve the + // rest; and listeners unsubscribed *during* the emit are skipped — a + // scopeReplaced consumer may tear down a later listener mid-emit (e.g. + // codemirror replacing its sync plugin), and invoking it anyway would + // re-apply state it already handed off. emit(ev: string, ...args: unknown[]): boolean { const set = this.#listeners.get(ev); if (!set || set.size === 0) return false; - for (const fn of [...set]) fn(...args); + for (const fn of [...set]) { + if (!set.has(fn)) continue; + try { + fn(...args); + } catch (err) { + console.error( + `[patchwork-providers] "${ev}" listener on ${this.#originalUrl} threw:`, + err + ); + } + } return true; } @@ -184,7 +257,7 @@ export class OverlayHandle { // re-stamping `payload.handle = this wrapper` so consumers see the wrapper // rather than the backing handle as the event source. #forward(ev: string): void { - if (this.#forwarded.has(ev)) return; + if (this.#forwarders.has(ev)) return; const forwarder: Listener = (payload: unknown) => { if ( payload && @@ -196,7 +269,7 @@ export class OverlayHandle { this.emit(ev, payload); } }; - this.#forwarded.set(ev, forwarder); + this.#forwarders.set(ev, forwarder); (this.#handle as unknown as { on(ev: string, fn: Listener): void }).on( ev, forwarder @@ -207,10 +280,10 @@ export class OverlayHandle { const backing = this.#handle as unknown as { off(ev: string, fn: Listener): void; }; - for (const [ev, forwarder] of this.#forwarded) backing.off(ev, forwarder); - this.#forwarded.clear(); + for (const [ev, forwarder] of this.#forwarders) backing.off(ev, forwarder); + this.#forwarders.clear(); this.#listeners.clear(); - for (const sub of this.#subCache.values()) sub.dispose(); + for (const entry of this.#subCache.values()) entry.wrapped.dispose(); this.#subCache.clear(); } } diff --git a/packages/providers/core/src/overlay-repo.ts b/packages/providers/core/src/overlay-repo.ts index aebb6f87..e543648c 100644 --- a/packages/providers/core/src/overlay-repo.ts +++ b/packages/providers/core/src/overlay-repo.ts @@ -12,7 +12,7 @@ import { type Repo, } from "@automerge/automerge-repo"; -import { request } from "./index.js"; +import { subscribe } from "./index.js"; import { forwardingProxy } from "./forwarding-proxy.js"; import { OverlayHandle } from "./overlay-handle.js"; import type { RepoLike } from "./types.js"; @@ -30,6 +30,11 @@ import type { RepoLike } from "./types.js"; * url, so a `cloneUrl` that is not a fork would silently break ref/cursor * identity. Remappers are responsible for upholding this. * + * The subscription is streaming: a remapper may emit a new descriptor at any + * time and the overlay repo swaps the live handle's backing in place — + * consumers keep the same wrapper and see a `change` with + * `scopeReplaced: true`. One-shot providers remain fully supported. + * * The descriptor crosses the `patchwork:subscribe` channel structured-cloned, * which is why it carries plain `AutomergeUrl` strings and never a live * `DocHandle`/`Repo`. @@ -57,12 +62,17 @@ const OVERLAY_REPO_OWNED: ReadonlySet = new Set([ * remappable across provider scopes (including iframes) without sending a live * `Repo`/`DocHandle` over the wire. * - * `find`/`findWithProgress` dispatch a `repo:handle-descriptor` subscription for - * the requested url and resolve the returned `cloneUrl ?? url` against the - * realm-local `baseRepo`, then hand back an {@link OverlayHandle} that keeps - * reporting the *original* url. Every other method (`create`, `create2`, - * `clone`, `delete`, the EventEmitter surface, ...) forwards to `baseRepo` - * unchanged — created docs need no remapping. + * `find`/`findWithProgress` open a persistent `repo:handle-descriptor` + * subscription for the requested url and resolve the returned + * `cloneUrl ?? url` against the realm-local `baseRepo`, then hand back an + * {@link OverlayHandle} that keeps reporting the *original* url. Follow-up + * descriptor emissions re-point the live wrapper via `swapBackingDocHandle`. + * Every other method (`create`, `create2`, `clone`, `delete`, the + * EventEmitter surface, ...) forwards to `baseRepo` unchanged — created docs + * need no remapping. + * + * The owning element must call {@link dispose} on disconnect to release the + * descriptor subscriptions. */ export class OverlayRepo implements RepoLike { readonly baseRepo: Repo; @@ -76,6 +86,16 @@ export class OverlayRepo implements RepoLike { AutomergeUrl, Promise> >(); + // Live descriptor subscriptions, one per presented url. + readonly #subscriptions = new Map void>(); + // Applied backing url per presented url; skips no-op re-emissions. + readonly #backingUrls = new Map(); + // Progress subscribers per presented url, mapped to their unsubscribe from + // the current inner; re-wired on swap. + readonly #progressDispatchers = new Map< + AutomergeUrl, + Map<() => void, () => void> + >(); #disposed = false; constructor(baseRepo: Repo, element: HTMLElement) { @@ -145,7 +165,7 @@ export class OverlayRepo implements RepoLike { // consumer's callback after it unsubscribed. let closed = false; let last: string | null = null; - let unsubscribeInner: () => void = () => {}; + let registered = false; const dispatch = () => { if (closed) return; const state = peek(); @@ -157,15 +177,19 @@ export class OverlayRepo implements RepoLike { last = sig; callback(state); }; + // Registered via the dispatcher registry (not on the inner directly) + // so a backing swap can re-wire this subscriber onto the new inner. wrappedPromise.then(() => { if (closed) return; - const inner = self.#inner.get(presented); - if (inner) unsubscribeInner = inner.subscribe(dispatch); + self.#registerProgressDispatcher(presented, dispatch); + registered = true; dispatch(); }, dispatch); return () => { closed = true; - unsubscribeInner(); + if (registered) { + self.#unregisterProgressDispatcher(presented, dispatch); + } }; }, whenReady: ({ signal } = {}) => { @@ -213,50 +237,100 @@ export class OverlayRepo implements RepoLike { return this.baseRepo.create2(initialValue); } + /** Called by the owning element on disconnect; releases live resources. */ dispose(): void { + if (this.#disposed) return; this.#disposed = true; + for (const unsubscribe of this.#subscriptions.values()) unsubscribe(); + this.#subscriptions.clear(); + for (const dispatchers of this.#progressDispatchers.values()) { + for (const unsubscribeInner of dispatchers.values()) unsubscribeInner(); + } + this.#progressDispatchers.clear(); for (const wrapped of this.#wrapped.values()) wrapped.dispose(); this.#wrapped.clear(); this.#inner.clear(); this.#resolving.clear(); + this.#backingUrls.clear(); } - // De-dupes concurrent resolutions of the same presented url: the first - // caller dispatches the subscription, everyone else awaits the same promise. + // De-dupes concurrent resolutions: the first caller opens the persistent + // descriptor subscription, everyone else awaits the same promise. Later + // emissions swap the resolved wrapper's backing in place. #resolve(presented: AutomergeUrl): Promise> { const existing = this.#resolving.get(presented); if (existing) return existing as Promise>; + // Don't open subscriptions nobody will tear down; the element is gone. + if (this.#disposed) { + return Promise.reject( + new Error("OverlayRepo is disposed; cannot resolve " + presented) + ); + } - const promise = (async () => { - // The descriptor channel remaps whole documents, so ask about the root - // and reapply the original path/heads onto whatever clone comes back. - // The clone is a fork of the root, so the same sub-tree exists in it. - const { documentId, segments, heads } = parseAutomergeUrl(presented); - const rootUrl = stringifyAutomergeUrl({ documentId }); - const descriptor = await request(this.#element, { - type: "repo:handle-descriptor", - url: rootUrl, - }); - const backingRoot = descriptor.cloneUrl ?? descriptor.url; - const backingUrl = stringifyAutomergeUrl({ - documentId: parseAutomergeUrl(backingRoot).documentId, - segments, - heads, - }); - const inner = this.baseRepo.findWithProgress(backingUrl); - this.#inner.set(presented, inner as DocumentProgress); - const backing = await this.baseRepo.find(backingUrl); - const wrapped = new OverlayHandle({ - presentedUrl: presented, - backing, - }); - if (this.#disposed) { - wrapped.dispose(); - return wrapped; - } - this.#wrapped.set(presented, wrapped as OverlayHandle); - return wrapped; - })(); + // The descriptor channel remaps whole documents, so ask about the root + // and reapply the original path/heads onto whatever clone comes back. + // The clone is a fork of the root, so the same sub-tree exists in it. + const { documentId, segments, heads } = parseAutomergeUrl(presented); + const rootUrl = stringifyAutomergeUrl({ documentId }); + + const promise = new Promise>((resolve, reject) => { + // Only the latest received descriptor may commit its backing. + let seq = 0; + + const apply = async (descriptor: DocHandleDescriptor) => { + const mySeq = ++seq; + const backingRoot = descriptor.cloneUrl ?? descriptor.url; + // A remapper may pin the backing to specific heads by stamping them + // onto the returned url; honor them, but presented-url heads win. + const parsedBacking = parseAutomergeUrl(backingRoot); + const backingUrl = stringifyAutomergeUrl({ + documentId: parsedBacking.documentId, + segments, + heads: heads ?? parsedBacking.heads, + }); + if (this.#backingUrls.get(presented) === backingUrl) return; + + const inner = this.baseRepo.findWithProgress(backingUrl); + const backing = await this.baseRepo.find(backingUrl); + if (mySeq !== seq || this.#disposed) return; + + this.#backingUrls.set(presented, backingUrl); + this.#setInner(presented, inner as DocumentProgress); + + const wrapped = this.#wrapped.get(presented) as + | OverlayHandle + | undefined; + if (wrapped) { + wrapped.swapBackingDocHandle(backing); + return; + } + const created = new OverlayHandle({ + presentedUrl: presented, + backing, + }); + this.#wrapped.set(presented, created as OverlayHandle); + resolve(created); + }; + + const unsubscribe = subscribe( + this.#element, + { type: "repo:handle-descriptor", url: rootUrl }, + (descriptor) => { + apply(descriptor).catch((err) => { + // Only the initial resolution can fail the returned promise; + // a failed re-map keeps the previous backing. + if (!this.#wrapped.has(presented)) reject(err); + else { + console.error( + `[patchwork-providers] failed to re-map ${presented}:`, + err + ); + } + }); + } + ); + this.#subscriptions.set(presented, unsubscribe); + }); // Only successful resolutions stay memoized. A rejection — typically // baseRepo.find reporting unavailable because the doc (or its keyhive @@ -275,6 +349,44 @@ export class OverlayRepo implements RepoLike { this.#resolving.set(presented, promise as Promise>); return promise; } + + // Attach a progress subscriber to the current inner (if any), keeping its + // unsubscribe so `#setInner` can re-wire it on a swap. + #registerProgressDispatcher( + presented: AutomergeUrl, + dispatch: () => void + ): void { + let dispatchers = this.#progressDispatchers.get(presented); + if (!dispatchers) { + dispatchers = new Map(); + this.#progressDispatchers.set(presented, dispatchers); + } + const inner = this.#inner.get(presented); + dispatchers.set(dispatch, inner ? inner.subscribe(dispatch) : () => {}); + } + + #unregisterProgressDispatcher( + presented: AutomergeUrl, + dispatch: () => void + ): void { + const dispatchers = this.#progressDispatchers.get(presented); + if (!dispatchers) return; + dispatchers.get(dispatch)?.(); + dispatchers.delete(dispatch); + if (dispatchers.size === 0) this.#progressDispatchers.delete(presented); + } + + // Replace the inner progress and move every registered subscriber onto it. + #setInner(presented: AutomergeUrl, inner: DocumentProgress): void { + this.#inner.set(presented, inner); + const dispatchers = this.#progressDispatchers.get(presented); + if (!dispatchers) return; + for (const [dispatch, unsubscribeOld] of dispatchers) { + unsubscribeOld(); + dispatchers.set(dispatch, inner.subscribe(dispatch)); + dispatch(); + } + } } // Canonicalize any id to its *presented* url, preserving the path suffix