diff --git a/Oqtane.Client/UI/Head.razor b/Oqtane.Client/UI/Head.razor index 0d72ce98..c3b0f667 100644 --- a/Oqtane.Client/UI/Head.razor +++ b/Oqtane.Client/UI/Head.razor @@ -58,27 +58,22 @@ var index = headcontent.IndexOf("= 0) { - var script = headcontent.Substring(index, headcontent.IndexOf("") + 9 - index); + var script = headcontent.Substring(index, headcontent.IndexOf("", index) - index + 9); if (RenderMode == RenderModes.Interactive) { // remove scripts when interactive rendering headcontent = headcontent.Remove(index, script.Length); + index = headcontent.IndexOf("<")) + if (!script.Contains("><") && !script.Contains("data-reload")) { - // convert inline script body to content attribute - var content = pagescript.Substring(pagescript.IndexOf(">") + 1, pagescript.LastIndexOf("<") - pagescript.IndexOf(">") - 1); - pagescript = pagescript.Replace(">" + content, " content=\"" + content.Replace("\"","'") + "\">"); + // add data-reload attribute to inline script + headcontent = headcontent.Replace(script, script.Replace(" } @((MarkupString)_headResources) + @if (_renderMode == RenderModes.Static) + { + + } @if (string.IsNullOrEmpty(_message)) @@ -547,31 +551,18 @@ private string CreateScript(Resource resource, Alias alias) { - var src = resource.Url; - if (!string.IsNullOrEmpty(src)) + if (!resource.Reload) { - src = (resource.Url.Contains("://")) ? resource.Url : alias.BaseUrl + resource.Url; - } - - if (resource.Level == ResourceLevel.Module || !string.IsNullOrEmpty(resource.Content) || resource.Reload) - { - return ""; + ">"; } else { - return ""; + return ""; } } diff --git a/Oqtane.Server/wwwroot/Oqtane.Server.lib.module.js b/Oqtane.Server/wwwroot/Oqtane.Server.lib.module.js index 41d385cb..508f42a0 100644 --- a/Oqtane.Server/wwwroot/Oqtane.Server.lib.module.js +++ b/Oqtane.Server/wwwroot/Oqtane.Server.lib.module.js @@ -1,194 +1,88 @@ const pageScriptInfoBySrc = new Map(); -function getKey(element) { - if (element.src !== "") { - return element.src; - } else { - return element.content; +function registerPageScriptElement(src) { + if (!src) { + throw new Error('Must provide a non-empty value for the "src" attribute.'); } -} -function registerPageScript(element) { - let key = getKey(element); - let pageScriptInfo = pageScriptInfoBySrc.get(key); + let pageScriptInfo = pageScriptInfoBySrc.get(src); if (pageScriptInfo) { pageScriptInfo.referenceCount++; } else { - if (element.src.startsWith("./")) { - element.src = new URL(element.src.substr(2), document.baseURI).toString(); - } - pageScriptInfo = { src: element.src, type: element.type, integrity: element.integrity, crossorigin: element.crossorigin, content: element.content, location: element.location, reload: element.reload, module: null, referenceCount: 1 }; - pageScriptInfoBySrc.set(key, pageScriptInfo); - initializePageScript(key, pageScriptInfo); + pageScriptInfo = { referenceCount: 1, module: null }; + pageScriptInfoBySrc.set(src, pageScriptInfo); + initializePageScriptModule(src, pageScriptInfo); } } -async function initializePageScript(key, pageScriptInfo) { - if (pageScriptInfo.reload) { - const module = await import(pageScriptInfo.src); - pageScriptInfo.module = module; - module.onLoad?.(); - module.onUpdate?.(); - } else { - if (!scriptExists(pageScriptInfo)) { - try { - injectScript(pageScriptInfo); - } catch (error) { - if (pageScriptInfo.src !== "") { - console.error("Failed to load external script: ${pageScriptInfo.src}", error); - } else { - console.error("Failed to load inline script: ${pageScriptInfo.content}", error); - } - } - } - } - removePageScript(key, pageScriptInfo); -} - -function onEnhancedLoad() { - for (const [key, pageScriptInfo] of pageScriptInfoBySrc) { - if (pageScriptInfo.referenceCount <= 0) { - if (pageScriptInfo.module) { - pageScriptInfo.module.onDispose?.(); - } - pageScriptInfoBySrc.delete(key); - } +function unregisterPageScriptElement(src) { + if (!src) { + return; } - for (const [key, pageScriptInfo] of pageScriptInfoBySrc) { - if (pageScriptInfo.module) { - pageScriptInfo.module.onUpdate?.(); - } else { - if (!scriptExists(pageScriptInfo)) { - try { - injectScript(pageScriptInfo); - } catch (error) { - if (pageScriptInfo.src !== "") { - console.error("Failed to load external script: ${pageScriptInfo.src}", error); - } else { - console.error("Failed to load inline script: ${pageScriptInfo.content}", error); - } - } - } - } - } - - for (const [key, pageScriptInfo] of pageScriptInfoBySrc) { - removePageScript(key, pageScriptInfo); - } -} - -function scriptExists(pageScriptInfo) { - if (pageScriptInfo.src !== "") { - return document.querySelector("script[src=\"" + pageScriptInfo.src + "\"]"); - } else { - const scripts = document.querySelectorAll('script:not([src])'); - for (let i = 0; i < scripts.length; i++) { - if (scripts[i].textContent.includes(pageScriptInfo.content)) { - return true; - } - } - return false; - } -} - -function injectScript(pageScriptInfo) { - return new Promise((resolve, reject) => { - var script = document.createElement("script"); - - script.async = false; - if (pageScriptInfo.type !== "") { - script.type = pageScriptInfo.type; - } - - if (pageScriptInfo.src !== "") { - script.src = pageScriptInfo.src; - if (pageScriptInfo.integrity !== "") { - script.integrity = pageScriptInfo.integrity; - } - if (pageScriptInfo.crossorigin !== "") { - script.crossOrigin = pageScriptInfo.crossorigin; - } - } else { - script.innerHTML = pageScriptInfo.content; - } - - script.onload = () => resolve(); - script.onerror = (error) => reject(error); - - // add script to page - document.head.appendChild(script); - }); -} - -function removePageScript(key, pageScriptInfo) { - var pageScript; - - if (pageScriptInfo.src !== "") { - pageScript = document.querySelector("page-script[src=\"" + key + "\"]"); - } else { - pageScript = document.querySelector("page-script[content=\"" + CSS.escape(pageScriptInfo.content) + "\"]"); - } - - if (pageScript) { - pageScript.remove(); - } -} - -function unregisterPageScript(element) { - const pageScriptInfo = pageScriptInfoBySrc.get(getKey(element)); + const pageScriptInfo = pageScriptInfoBySrc.get(src); if (!pageScriptInfo) { return; } + pageScriptInfo.referenceCount--; } +async function initializePageScriptModule(src, pageScriptInfo) { + // If the path is relative, normalize it by by making it an absolute URL + // with document's the base HREF. + if (src.startsWith("./")) { + src = new URL(src.substr(2), document.baseURI).toString(); + } + + const module = await import(src); + + if (pageScriptInfo.referenceCount <= 0) { + // All page-script elements with the same 'src' were + // unregistered while we were loading the module. + return; + } + + pageScriptInfo.module = module; + module.onLoad?.(); + module.onUpdate?.(); +} + +function onEnhancedLoad() { + // Start by invoking 'onDispose' on any modules that are no longer referenced. + for (const [src, { module, referenceCount }] of pageScriptInfoBySrc) { + if (referenceCount <= 0) { + module?.onDispose?.(); + pageScriptInfoBySrc.delete(src); + } + } + + // Then invoke 'onUpdate' on the remaining modules. + for (const { module } of pageScriptInfoBySrc.values()) { + module?.onUpdate?.(); + } +} + export function afterWebStarted(blazor) { customElements.define('page-script', class extends HTMLElement { - static observedAttributes = ['src', 'type', 'integrity', 'crossorigin', 'content', 'reload']; - - constructor() { - super(); - - this.src = ""; - this.type = ""; - this.integrity = ""; - this.crossorigin = ""; - this.content = ""; - this.reload = false; - } + static observedAttributes = ['src']; + // We use attributeChangedCallback instead of connectedCallback + // because a page-script element might get reused between enhanced + // navigations. attributeChangedCallback(name, oldValue, newValue) { - switch (name) { - case "src": - this.src = newValue; - break; - case "type": - this.type = newValue; - break; - case "integrity": - this.integrity = newValue; - break; - case "crossorigin": - this.crossorigin = newValue; - break; - case "content": - this.content = newValue; - break; - case "reload": - this.reload = newValue; - break; + if (name !== 'src') { + return; } - // if last attribute for element has been processed - if (this.attributes[this.attributes.length - 1].name === name) { - registerPageScript(this); - } + this.src = newValue; + unregisterPageScriptElement(oldValue); + registerPageScriptElement(newValue); } disconnectedCallback() { - unregisterPageScript(this); + unregisterPageScriptElement(this.src); } }); diff --git a/Oqtane.Server/wwwroot/js/reload.js b/Oqtane.Server/wwwroot/js/reload.js new file mode 100644 index 00000000..b30e8f9a --- /dev/null +++ b/Oqtane.Server/wwwroot/js/reload.js @@ -0,0 +1,87 @@ +const scriptInfoBySrc = new Map(); + +function getKey(script) { + if (script.hasAttribute("src") && script.src !== "") { + return script.src; + } else { + return script.innerHTML; + } +} + +export function onUpdate() { + let timestamp = Date.now(); + let enhancedNavigation = scriptInfoBySrc.size !== 0; + + // iterate over all script elements in page + const scripts = document.getElementsByTagName("script"); + for (const script of scripts) { + let key = getKey(script); + let scriptInfo = scriptInfoBySrc.get(key); + if (!scriptInfo) { + // new script added + scriptInfo = { timestamp: timestamp }; + scriptInfoBySrc.set(key, scriptInfo); + if (enhancedNavigation) { + reloadScript(script); + } + } else { + // existing script + scriptInfo.timestamp = timestamp; + if (script.hasAttribute("data-reload") && script.getAttribute("data-reload") === "true") { + reloadScript(script); + } + } + } + + // remove scripts that are no longer referenced + for (const [key, scriptInfo] of scriptInfoBySrc) { + if (scriptInfo.timestamp !== timestamp) { + scriptInfoBySrc.delete(key); + } + } +} + +function reloadScript(script) { + try { + replaceScript(script); + } catch (error) { + if (script.hasAttribute("src") && script.src !== "") { + console.error("Failed to load external script: ${script.src}", error); + } else { + console.error("Failed to load inline script: ${script.innerHtml}", error); + } + } +} + +function replaceScript(script) { + return new Promise((resolve, reject) => { + var newScript = document.createElement("script"); + + newScript.async = false; + if (script.type !== "") { + newScript.type = script.type; + } + + if (script.src !== "") { + newScript.src = script.src; + if (script.integrity !== "") { + newScript.integrity = script.integrity; + } + if (script.crossorigin !== "") { + newScript.crossOrigin = script.crossOrigin; + } + } else { + newScript.innerHTML = script.innerHTML; + } + + newScript.onload = () => resolve(); + newScript.onerror = (error) => reject(error); + + // remove existing newScript script + script.remove(); + + // replace with new newScript to force reload in Blazor + document.head.appendChild(newScript); + }); +} +