mirror of
https://github.com/Jermolene/TiddlyWiki5
synced 2026-11-02 04:54:27 +00:00
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:
1 parent
5577a3d07b
commit
47806feb85
4 files changed
+195
-8
No files matched your search
@@ -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);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
+22
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user