Fix plugin library iframes hanging if onload never fires by adding readiness handshake (#10003)

* Fix race condition in plugin library iframe message handling

Introduce a PLUGIN-LIBRARY-READY handshake verb for plugin library iframes. 
This ensures that postMessage commands are not prematurely dropped before 
the iframe's internal script has fully bound its message listeners. 

Retains iframe.onload as a backward-compatible fallback for legacy or 
external libraries that do not emit the ready signal.

* add changenote

* feat(plugin-library): add PLUGIN-LIBRARY-READY handshake to plugin library iframe

    Add PLUGIN-LIBRARY-READY message emission in libraryserver.js

    Signals to parent window when iframe is ready to receive messages

    Complements browser-messaging.js PLUGIN-LIBRARY-READY handler

    Includes comprehensive tests for callback queueing and handshake flow

    Fixes race condition in plugin library iframe message handling

* fix(plugin-library): emit PLUGIN-LIBRARY-READY message on iframe initialization

Emit the PLUGIN-LIBRARY-READY message after the message listener is bound,

signaling to the parent window that the iframe is ready to receive postMessage

commands. This completes the handshake implementation to eliminate the race

condition where messages could be lost before the iframe's script initialization

completes.

* fix indentation

* remove extra space

* remove typo

* refactor(test-browser-messaging): update test descriptions and remove unused code

* lint fix

* fix: correct the update note
This commit is contained in:
Théophile Desmedt authored and GitHub committed 2026-09-08 09:28:15 +01:00
1 parent 5577a3d07b
commit 47806feb85
4 files changed
+195 -8

No files matched your search

+28 -8
View File
@@ -4,7 +4,6 @@ type: application/javascript
module-type: startup
Browser message handling
\*/
"use strict";
@@ -18,6 +17,20 @@ exports.synchronous = true;
/*
Load a specified url as an iframe and call the callback when it is loaded. If the url is already loaded then the existing iframe instance is used
*/
// Ensure the callback fires once, whether by PLUGIN-LIBRARY-READY or onload (legacy)
function flushCallbacks(iframeInfo, err) {
if(iframeInfo.status !== "loaded") {
iframeInfo.status = err ? "error" : "loaded";
saveIFrameInfoTiddler(iframeInfo);
var cb;
while((cb = iframeInfo.callbacks.shift())) {
cb(err, iframeInfo);
}
}
}
function loadIFrame(url,callback) {
// Check if iframe already exists
var iframeInfo = $tw.browserMessaging.iframeInfoMap[url];
@@ -30,7 +43,8 @@ function loadIFrame(url,callback) {
iframeInfo = {
url: url,
status: "loading",
domNode: iframe
domNode: iframe,
callbacks: [callback]
};
$tw.browserMessaging.iframeInfoMap[url] = iframeInfo;
saveIFrameInfoTiddler(iframeInfo);
@@ -38,19 +52,18 @@ function loadIFrame(url,callback) {
iframe.style.display = "none";
iframe.setAttribute("library","true");
document.body.appendChild(iframe);
// Set up onload
// Set up onload. Legacy fallback: if PLUGIN-LIBRARY-READY never arrives, onload triggers the flush later
iframe.onload = function() {
iframeInfo.status = "loaded";
saveIFrameInfoTiddler(iframeInfo);
callback(null,iframeInfo);
flushCallbacks(iframeInfo);
};
iframe.onerror = function() {
callback("Cannot load iframe");
flushCallbacks(iframeInfo, "Cannot load iframe");
};
try {
iframe.src = url;
} catch(ex) {
callback(ex);
flushCallbacks(iframeInfo, ex);
}
}
}
@@ -147,6 +160,13 @@ exports.startup = function() {
// console.log("browser-messaging: Received message from",event.origin);
// console.log("browser-messaging: Message content",event.data);
switch(event.data.verb) {
case "PLUGIN-LIBRARY-READY":
$tw.utils.each($tw.browserMessaging.iframeInfoMap, function(info) {
if(info && info.domNode && info.domNode.contentWindow === event.source) {
flushCallbacks(info);
}
});
break;
case "GET-RESPONSE":
if(event.data.status.charAt(0) === "2") {
if(event.data.cookies) {
@@ -0,0 +1,140 @@
/*\
title: test-browser-messaging.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests the PLUGIN-LIBRARY-READY handshake and legacy onload fallback.
\*/
"use strict";
describe("Browser Messaging", function() {
it("should declare itself as a synchronous browser-only startup module", function() {
var startup = require("$:/core/modules/browser-messaging.js");
expect(startup.name).toBe("browser-messaging");
expect(startup.platforms).toEqual(["browser"]);
expect(startup.synchronous).toBe(true);
expect(typeof startup.startup).toBe("function");
});
// loadIFrame()/flushCallbacks() are private to browser-messaging.js, so the only way
// to exercise them for real is through $tw.rootWidget events and real postMessage
// traffic with a real iframe, rather than re-implementing their logic here.
describe("plugin library iframe handshake", function() {
var urlsToUnload;
beforeEach(function() {
if(!$tw.browser) { return; }
urlsToUnload = [];
});
afterEach(function() {
if(!$tw.browser) { return; }
urlsToUnload.forEach(function(url) {
// Grab the DOM node before unloading clears the map entry, as a
// belt-and-braces removal (unloadIFrame's own removal has an
// unrelated off-by-one loop bug for small iframe counts).
var info = $tw.browserMessaging.iframeInfoMap[url];
$tw.rootWidget.dispatchEvent({type: "tm-unload-plugin-library", paramObject: {url: url}});
if(info && info.domNode && info.domNode.parentNode) {
info.domNode.parentNode.removeChild(info.domNode);
}
URL.revokeObjectURL(url);
});
});
function makeChildUrl(script) {
var blob = new Blob(["<script>" + script + "<\/script>"],{type: "text/html"});
var url = URL.createObjectURL(blob);
urlsToUnload.push(url);
return url;
}
function waitUntil(predicate,done,description) {
var attempts = 0;
(function check() {
if(predicate()) {
done();
} else if(++attempts > 400) {
done.fail("Timed out waiting for: " + description);
} else {
setTimeout(check,20);
}
})();
}
it("should complete the real GET / GET-RESPONSE round trip once the child signals PLUGIN-LIBRARY-READY", function(done) {
if(!$tw.browser) { pending("browser-only: requires a real iframe and postMessage - run in browser"); return; }
// Mirrors plugins/tiddlywiki/pluginlibrary/libraryserver.js's real handshake and GET handling:
// listener registration and the ready postMessage both happen synchronously, before <body> parses,
// so onload (the legacy fallback below) cannot win this race for a well-behaved child.
var url = makeChildUrl(
"window.addEventListener('message',function(e){" +
"if(e.data && e.data.verb === 'GET' && e.data.url === 'recipes/library/tiddlers.json'){" +
"e.source.postMessage({verb:'GET-RESPONSE',status:'200',cookies:e.data.cookies,url:e.data.url," +
"type:'application/json',body:JSON.stringify([{title:'ExamplePlugin',type:'application/json',text:'{}'}])},'*');" +
"}});" +
"window.parent.postMessage({verb:'PLUGIN-LIBRARY-READY'},'*');"
);
var expectedTitle = "$:/temp/RemoteAssetInfo/" + url + "/ExamplePlugin";
$tw.rootWidget.dispatchEvent({
type: "tm-load-plugin-library",
paramObject: {url: url}
});
waitUntil(function() {
return !!$tw.wiki.getTiddler(expectedTitle);
},done,"tiddler " + expectedTitle);
},10000);
it("should still resolve via the legacy iframe.onload fallback when the child never sends PLUGIN-LIBRARY-READY", function(done) {
if(!$tw.browser) { pending("browser-only: requires a real iframe - run in browser"); return; }
// A blank document that never sends the handshake exercises the onload-only legacy path.
var url = makeChildUrl("");
$tw.rootWidget.dispatchEvent({
type: "tm-load-plugin-library",
paramObject: {url: url}
});
waitUntil(function() {
var info = $tw.browserMessaging.iframeInfoMap[url];
return !!info && info.status === "loaded";
},done,"iframe status to become 'loaded' via onload");
},10000);
it("should complete the round trip via PLUGIN-LIBRARY-READY even when the child's own load is aborted and onload can never fire", function(done) {
if(!$tw.browser) { pending("browser-only: requires a real iframe and postMessage - run in browser"); return; }
// window.stop() simulates onload never firing despite the library being ready,
// so only the PLUGIN-LIBRARY-READY message can complete this round trip.
var url = makeChildUrl(
"window.addEventListener('message',function(e){" +
"if(e.data && e.data.verb === 'GET' && e.data.url === 'recipes/library/tiddlers.json'){" +
"e.source.postMessage({verb:'GET-RESPONSE',status:'200',cookies:e.data.cookies,url:e.data.url," +
"type:'application/json',body:JSON.stringify([{title:'ExamplePlugin',type:'application/json',text:'{}'}])},'*');" +
"}});" +
"window.parent.postMessage({verb:'PLUGIN-LIBRARY-READY'},'*');" +
"window.stop();"
);
var expectedTitle = "$:/temp/RemoteAssetInfo/" + url + "/ExamplePlugin";
$tw.rootWidget.dispatchEvent({
type: "tm-load-plugin-library",
paramObject: {url: url}
});
waitUntil(function() {
return !!$tw.wiki.getTiddler(expectedTitle);
},done,"tiddler " + expectedTitle);
},10000);
});
});
@@ -0,0 +1,22 @@
title: $:/changenotes/5.5.0/#10003
description: Fix race condition in plugin library iframe message handling
release: 5.5.0
tags: $:/tags/ChangeNote
change-type: bugfix
change-category: plugin
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/10003
github-contributors: DesignThinkerer
Introduce a ''PLUGIN-LIBRARY-READY'' handshake verb for plugin library iframes.
This ensures that postMessage commands are not prematurely dropped before the iframe's
internal script has fully bound its message listeners.
Retains iframe.onload as a backward-compatible fallback for legacy or external libraries
that do not emit the ready signal.
Previously, the browser-messaging.js relied entirely on iframe.onload to trigger the initial
postMessage call to a plugin library. However, if the iframe's connection or load is aborted
after its internal script has already run and bound window.addEventListener("message"), onload
never fires at all, so the initial postMessage call is never sent and the request is silently
lost with no error. The new handshake eliminates this by having the iframe actively confirm
readiness as soon as its listener is bound, independent of whether onload ever fires.
@@ -63,6 +63,11 @@ window.addEventListener("message",function listener(event){
}
},false);
// Signal to parent that the iframe is ready to receive messages
window.parent.postMessage({
verb: "PLUGIN-LIBRARY-READY"
},"*");
// Helper to remove string prefixes
function removePrefix(string,prefix) {
if(string.indexOf(prefix) === 0) {