From fef016d3af586eb9051b092d429cece70c646dee Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Mon, 31 Aug 2026 23:29:35 +0200 Subject: [PATCH] fix: guide "add sample content" button when multiple tabs are open (includes broadcasting changes) --- src/common/element.js | 29 +++++++++++++++++------------ src/common/pages/guide.js | 18 ++++++++++++------ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/common/element.js b/src/common/element.js index dbe2bf9f..8ff6e819 100644 --- a/src/common/element.js +++ b/src/common/element.js @@ -315,6 +315,12 @@ export class BroadcastableDiffuseElement extends DiffuseElement { this.broadcasted = true; this.channelName = channelName; + // A message sent on the channel is echoed back to its sender, which + // would execute a replicated/forwarded action twice (once via the local + // `ogFn`, once via the echo). Tag outgoing messages so each instance + // can ignore its own echoes. + const senderId = crypto.randomUUID(); + /** @type {RpcChannel<{}, Actions>} */ const _rpc = rpc( msg.port2, @@ -330,6 +336,7 @@ export class BroadcastableDiffuseElement extends DiffuseElement { channel.addEventListener( "message", async (event) => { + if (event.data?.__sender === senderId) return; if (event.data?.method?.startsWith("leader:")) { const status = await this.#status.promise; if (status.leader) { @@ -346,18 +353,12 @@ export class BroadcastableDiffuseElement extends DiffuseElement { msg.port1.addEventListener( "message", - (event) => channel.postMessage(event.data), + (event) => channel.postMessage({ ...event.data, __sender: senderId }), ); msg.port1.start(); msg.port2.start(); - async function anyoneWaiting() { - if (typeof navigator.locks?.query !== "function") return false; - const state = await navigator.locks.query(); - return !!state.pending?.length; - } - /** @type {RpcChannel<{}, Actions>} */ const proxyChannel = new RpcChannel(msg.port2); @@ -384,11 +385,15 @@ export class BroadcastableDiffuseElement extends DiffuseElement { break; case "replicate": - /** @param {Parameters} args */ - wrapFn = async (...args) => { - anyoneWaiting().then((bool) => { - if (bool) proxy[action](...args); - }); + // Replicated actions run locally through `ogFn` and are also + // forwarded so the other instances (including the leader, the + // only one that persists) run them too. The sender ignores its + // own echo, so the action isn't executed twice in this tab. + // Forwarding unconditionally, rather than only when some lock + // happens to be pending, is what keeps a follower's writes from + // being silently lost. + wrapFn = (...args) => { + proxy[action](...args).catch(() => {}); return ogFn(...args); }; break; diff --git a/src/common/pages/guide.js b/src/common/pages/guide.js index cb96b2fd..01020808 100644 --- a/src/common/pages/guide.js +++ b/src/common/pages/guide.js @@ -29,10 +29,17 @@ async function addSampleContent() { ); /** @type {import("~/components/input/s3/element.js").CLASS | null} */ - const s3 = input.querySelector("di-s3"); + let s3 = input.querySelector("di-s3"); if (!s3) { - throw new Error("S3 input not found"); + // The input-bundle prelude normally adds the S3 input, but it may not + // have been inserted in this tab (e.g. when there are no saved facets + // yet), so create it directly. + const { CLASS: S3Input } = await import( + "~/components/input/s3/element.js" + ); + s3 = new S3Input(); + input.append(s3); } addDemoBtn.innerHTML = ` @@ -42,10 +49,9 @@ async function addSampleContent() { const demo = await s3.demo(); - await output.tracks.save([ - ...(await Output.data(output.tracks)), - demo.track, - ]); + await output.tracks.save( + Output.mergeById(await Output.data(output.tracks), [demo.track]), + ); addDemoBtn.innerHTML = ` -- 2.51.2