release: WRNexusJS 0.8.3
This commit is contained in:
@@ -702,5 +702,10 @@ export const NAV_RUNTIME = String.raw`
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
/** Re-render the current route without adding a duplicate history entry. */
|
||||
window.__wrnexusRefresh = function () {
|
||||
navigate(location.href, true);
|
||||
};
|
||||
})();
|
||||
`.trim();
|
||||
|
||||
@@ -363,6 +363,26 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function routeSnapshot() {
|
||||
var url = new URL(window.location.href);
|
||||
var params = window.__wrnexusRouteParams || {};
|
||||
var marker = document.querySelector("[data-wrn-route-params]");
|
||||
if (marker) {
|
||||
try { params = JSON.parse(marker.getAttribute("data-wrn-route-params") || "{}"); }
|
||||
catch (_) { params = {}; }
|
||||
window.__wrnexusRouteParams = params;
|
||||
marker.remove();
|
||||
}
|
||||
return {
|
||||
url: url.href,
|
||||
pathname: url.pathname,
|
||||
search: url.search,
|
||||
hash: url.hash,
|
||||
searchParams: url.searchParams,
|
||||
params: params,
|
||||
};
|
||||
}
|
||||
function sanitizeReactiveUrl(value) {
|
||||
var raw = String(value == null ? "" : value);
|
||||
var compact = raw.trim().replace(/[\u0000-\u0020]+/g, "").toLowerCase();
|
||||
@@ -755,6 +775,20 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var anyStateListeners = new Set();
|
||||
var cleanupCallbacks = [];
|
||||
var disposed = false;
|
||||
var routeValue = signal(routeSnapshot());
|
||||
|
||||
function updateRouteValue() {
|
||||
routeValue.set(routeSnapshot());
|
||||
}
|
||||
|
||||
window.addEventListener("wrnexus:navigated", updateRouteValue);
|
||||
window.addEventListener("popstate", updateRouteValue);
|
||||
window.addEventListener("hashchange", updateRouteValue);
|
||||
cleanupCallbacks.push(function () {
|
||||
window.removeEventListener("wrnexus:navigated", updateRouteValue);
|
||||
window.removeEventListener("popstate", updateRouteValue);
|
||||
window.removeEventListener("hashchange", updateRouteValue);
|
||||
});
|
||||
|
||||
function readGlobal(name) {
|
||||
if (declaredEvents.has(name)) {
|
||||
@@ -785,6 +819,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
if (name === "location") return window.location;
|
||||
if (name === "history") return window.history;
|
||||
if (name === "navigator") return window.navigator;
|
||||
if (name === "$route" || name === "route") {
|
||||
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
||||
return routeValue.get();
|
||||
}
|
||||
if (name === "setTimeout") return window.setTimeout.bind(window);
|
||||
if (name === "clearTimeout") return window.clearTimeout.bind(window);
|
||||
if (name === "setInterval") return window.setInterval.bind(window);
|
||||
@@ -965,6 +1003,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
|
||||
function installBehaviorFunctions(source) {
|
||||
extractBehaviorFunctions(source || "").forEach(function (definition) {
|
||||
// A generated browser module contains the real JavaScript implementation
|
||||
// of client/shared functions. Keep that implementation when it has already
|
||||
// been installed instead of replacing it with the small CSP-safe fallback
|
||||
// interpreter. The fallback intentionally supports only a bounded subset of
|
||||
// JavaScript and must never shadow the compiled function.
|
||||
if (typeof behaviorFunctions[definition.name] === "function") return;
|
||||
behaviorFunctions[
|
||||
definition.name
|
||||
] = function () {
|
||||
@@ -3665,15 +3709,8 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
return special.value;
|
||||
}
|
||||
|
||||
// Template literals are evaluated without eval so they remain compatible
|
||||
// with a strict CSP. Each interpolation uses the same bounded expression
|
||||
// evaluator as every other reactive binding.
|
||||
if (
|
||||
expr.length >= 2 &&
|
||||
expr[0] === "\`" &&
|
||||
expr[expr.length - 1] === "\`"
|
||||
) {
|
||||
var template = expr.slice(1, -1);
|
||||
function evaluateTemplateLiteral(source) {
|
||||
var template = source.slice(1, -1);
|
||||
var rendered = "";
|
||||
var cursor = 0;
|
||||
|
||||
@@ -3703,7 +3740,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
else if (character === "}" && --depth === 0) break;
|
||||
}
|
||||
|
||||
if (depth !== 0) throw new Error("Unclosed template interpolation in '" + expr + "'");
|
||||
if (depth !== 0) throw new Error("Unclosed template interpolation in '" + source + "'");
|
||||
rendered += String(evaluateExpression(template.slice(expressionStart, expressionEnd), read));
|
||||
cursor = expressionEnd + 1;
|
||||
continue;
|
||||
@@ -3715,6 +3752,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
return rendered;
|
||||
}
|
||||
|
||||
// Template literals are evaluated without eval so they remain compatible
|
||||
// with a strict CSP. Each interpolation uses the same bounded expression
|
||||
// evaluator as every other reactive binding. They are accepted both as a
|
||||
// complete expression and as a primary inside a larger expression.
|
||||
if (
|
||||
expr.length >= 2 &&
|
||||
expr[0] === "\`" &&
|
||||
expr[expr.length - 1] === "\`"
|
||||
) {
|
||||
return evaluateTemplateLiteral(expr);
|
||||
}
|
||||
|
||||
var tokens =
|
||||
tokenizeExpression(expr);
|
||||
|
||||
@@ -3731,6 +3780,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var t = next();
|
||||
if (!t) throw new Error("Unexpected end of expression in '" + expr + "'");
|
||||
if (t.type === "number" || t.type === "string") return { value: t.value };
|
||||
if (t.type === "template") return { value: evaluateTemplateLiteral(t.value) };
|
||||
if (t.type === "ident") {
|
||||
if (t.value === "true") return { value: true };
|
||||
if (t.value === "false") return { value: false };
|
||||
@@ -3874,6 +3924,37 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
tokens.push({ type: "string", value: value });
|
||||
continue;
|
||||
}
|
||||
if (ch === "\`") {
|
||||
var templateStart = i++;
|
||||
var templateDepth = 0;
|
||||
var templateQuote = "";
|
||||
while (i < input.length) {
|
||||
ch = input[i++];
|
||||
if (templateQuote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === templateQuote) templateQuote = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\`" && templateDepth === 0) break;
|
||||
if (ch === "$" && input[i] === "{") {
|
||||
templateDepth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (templateDepth > 0) {
|
||||
if (ch === '"' || ch === "'") templateQuote = ch;
|
||||
else if (ch === "{") templateDepth++;
|
||||
else if (ch === "}") templateDepth--;
|
||||
}
|
||||
}
|
||||
if (input[i - 1] !== "\`") throw new Error("Unclosed template literal");
|
||||
tokens.push({ type: "template", value: input.slice(templateStart, i) });
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_$]/.test(ch)) {
|
||||
var s = i++;
|
||||
while (i < input.length && /[A-Za-z0-9_$]/.test(input[i])) i++;
|
||||
@@ -4047,6 +4128,23 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
Array.from(node.childNodes || []).forEach(visit);
|
||||
}
|
||||
visit(root);
|
||||
root.querySelectorAll("*").forEach(function (node) {
|
||||
Array.from(node.attributes || []).forEach(function (attribute) {
|
||||
if (attribute.name.indexOf("data-wrn-async-") === 0) return;
|
||||
if (attribute.value.indexOf("{") === -1) return;
|
||||
var nextValue = String(attribute.value).replace(/\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g, function (whole, path) {
|
||||
var parts = path.split(".");
|
||||
if (parts[0] === prefix) parts.shift();
|
||||
if (prefix === "error" && parts[0] !== "message") return whole;
|
||||
var value = asyncValueAt(model, parts.join("."));
|
||||
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
});
|
||||
if (/^(?:href|src|action|formaction)$/i.test(attribute.name)) {
|
||||
nextValue = sanitizeReactiveUrl(nextValue);
|
||||
}
|
||||
node.setAttribute(attribute.name, nextValue);
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-wrn-async-value]").forEach(function (node) {
|
||||
var path = node.getAttribute("data-wrn-async-value") || "";
|
||||
var value = asyncValueAt(model, path);
|
||||
@@ -4058,7 +4156,9 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var content = boundary.querySelector("[data-wrn-async-content]");
|
||||
if (!template || !content) return;
|
||||
var fragment = template.content.cloneNode(true);
|
||||
interpolateAsync(fragment, model, state === "error" ? "error" : boundary.getAttribute("data-wrn-async"));
|
||||
var alias = template.getAttribute("data-wrn-async-alias") ||
|
||||
(state === "error" ? "error" : boundary.getAttribute("data-wrn-async"));
|
||||
interpolateAsync(fragment, model, alias);
|
||||
content.replaceChildren(fragment);
|
||||
boundary.setAttribute("aria-busy", state === "loading" ? "true" : "false");
|
||||
boundary.setAttribute("data-wrn-async-state", state);
|
||||
@@ -4073,7 +4173,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
var name = boundary.getAttribute("data-wrn-async") || "";
|
||||
if (!/^[A-Za-z_$][\w$]{0,63}$/.test(name)) return showAsync(boundary, "error", { message: "Invalid data source" });
|
||||
var url = "/__wrnexus/client-load?route=" + encodeURIComponent(location.pathname) + "&name=" + encodeURIComponent(name);
|
||||
var url = "/__wrnexus/client-load?route=" + encodeURIComponent(location.pathname) + "&search=" + encodeURIComponent(location.search) + "&name=" + encodeURIComponent(name);
|
||||
var attempts = Math.min(5, Math.max(0, Number(boundary.getAttribute("data-wrn-async-retries")) || 0));
|
||||
var controller = new AbortController();
|
||||
boundary.__wrnexusAsyncAbort = controller;
|
||||
@@ -4102,6 +4202,44 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
(root || document).querySelectorAll("[data-wrn-async]").forEach(setupAsyncBoundary);
|
||||
}
|
||||
|
||||
var invalidationRefreshPending = false;
|
||||
function asyncBoundaryTags(boundary) {
|
||||
var raw = boundary.getAttribute("data-wrn-async-tags") || boundary.getAttribute("data-wrn-async") || "";
|
||||
return raw.split(",").map(function (tag) { return tag.trim(); }).filter(Boolean);
|
||||
}
|
||||
function refreshAsyncBoundary(boundary) {
|
||||
if (boundary.__wrnexusAsyncAbort) {
|
||||
try { boundary.__wrnexusAsyncAbort.abort(); } catch (_) {}
|
||||
}
|
||||
boundary.__wrnexusAsync = false;
|
||||
boundary.removeAttribute("data-wrn-async-resolved");
|
||||
setupAsyncBoundary(boundary);
|
||||
}
|
||||
function handleCacheInvalidation(event) {
|
||||
var detail = event && event.detail && typeof event.detail === "object" ? event.detail : {};
|
||||
var tags = Array.isArray(detail.tags) ? detail.tags.map(String) : [];
|
||||
if (!tags.length) return;
|
||||
var matchedServerBoundary = false;
|
||||
document.querySelectorAll("[data-wrn-async]").forEach(function (boundary) {
|
||||
var matches = asyncBoundaryTags(boundary).some(function (tag) { return tags.indexOf(tag) !== -1; });
|
||||
if (!matches) return;
|
||||
if (boundary.getAttribute("data-wrn-async-resolved") === "true") {
|
||||
matchedServerBoundary = true;
|
||||
return;
|
||||
}
|
||||
refreshAsyncBoundary(boundary);
|
||||
});
|
||||
if (matchedServerBoundary && !invalidationRefreshPending) {
|
||||
invalidationRefreshPending = true;
|
||||
Promise.resolve().then(function () {
|
||||
invalidationRefreshPending = false;
|
||||
if (typeof window.__wrnexusRefresh === "function") window.__wrnexusRefresh();
|
||||
else window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
window.addEventListener("wrnexus:cache:invalidate", handleCacheInvalidation);
|
||||
|
||||
function mountClientRoots(root) {
|
||||
(root || document).querySelectorAll("template[data-wrn-client-template]").forEach(function (template) {
|
||||
var id = template.getAttribute("data-wrn-client-template");
|
||||
|
||||
@@ -23,10 +23,29 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
if (!("WebSocket" in window)) return;
|
||||
var wire = (window.wire = window.wire || {});
|
||||
if (wire.room) return; // already installed
|
||||
var open = {}; // name -> room connection
|
||||
var open = {}; // normalized room+query key -> room connection
|
||||
|
||||
function normalizedQuery(query) {
|
||||
var params = new URLSearchParams(String(query || "").replace(/^\?/, ""));
|
||||
var pairs = [];
|
||||
params.forEach(function (value, name) { pairs.push([name, value]); });
|
||||
pairs.sort(function (a, b) {
|
||||
return a[0] === b[0] ? String(a[1]).localeCompare(String(b[1])) : String(a[0]).localeCompare(String(b[0]));
|
||||
});
|
||||
var normalized = new URLSearchParams();
|
||||
pairs.forEach(function (pair) { normalized.append(pair[0], pair[1]); });
|
||||
return normalized.toString();
|
||||
}
|
||||
|
||||
function roomKey(name, query) {
|
||||
var normalized = normalizedQuery(query);
|
||||
return String(name || "") + (normalized ? "?" + normalized : "");
|
||||
}
|
||||
|
||||
function openRoom(name, query) {
|
||||
if (open[name]) return open[name];
|
||||
query = normalizedQuery(query);
|
||||
var key = roomKey(name, query);
|
||||
if (open[key]) return open[key];
|
||||
var ws = null, queue = [], listeners = [], attempts = 0, timer = null, closed = false;
|
||||
|
||||
function url() {
|
||||
@@ -69,6 +88,8 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
|
||||
var api = {
|
||||
name: name,
|
||||
key: key,
|
||||
query: query,
|
||||
send: function (obj) {
|
||||
var payload = typeof obj === "string" ? obj : JSON.stringify(obj);
|
||||
if (ws && ws.readyState === 1) ws.send(payload);
|
||||
@@ -80,9 +101,9 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
listeners.push({ type: type, cb: cb });
|
||||
return api;
|
||||
},
|
||||
close: function () { closed = true; clearTimeout(timer); if (ws) try { ws.close(); } catch (_) {} ws = null; delete open[name]; },
|
||||
close: function () { closed = true; clearTimeout(timer); if (ws) try { ws.close(); } catch (_) {} ws = null; delete open[key]; },
|
||||
};
|
||||
open[name] = api;
|
||||
open[key] = api;
|
||||
connect();
|
||||
return api;
|
||||
}
|
||||
@@ -114,11 +135,14 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
function bindContainer(el) {
|
||||
if (el.__wireRoomBound) return;
|
||||
el.__wireRoomBound = true;
|
||||
var name = el.getAttribute("data-room");
|
||||
var user = el.getAttribute("data-room-user");
|
||||
var room = openRoom(name, user ? "user=" + encodeURIComponent(user) : "");
|
||||
var query = el.getAttribute("data-room-query") || (user ? "user=" + encodeURIComponent(user) : "");
|
||||
var key = roomKey(name, query);
|
||||
if (el.__wireRoomBound && el.__wireRoomKey === key) return;
|
||||
el.__wireRoomBound = true;
|
||||
el.__wireRoomKey = key;
|
||||
var room = openRoom(name, query);
|
||||
el.__wireRoom = room;
|
||||
|
||||
var log = el.querySelector("[data-room-log]");
|
||||
@@ -161,7 +185,7 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
var input = form.elements[i];
|
||||
if (input.name) data[input.name] = input.value;
|
||||
}
|
||||
room.send(data);
|
||||
if (el.__wireRoom) el.__wireRoom.send(data);
|
||||
for (var j = 0; j < form.elements.length; j++) {
|
||||
if (form.elements[j].hasAttribute("data-room-reset")) form.elements[j].value = "";
|
||||
}
|
||||
@@ -173,8 +197,8 @@ export const REALTIME_RUNTIME = String.raw`
|
||||
var containers = (root || document).querySelectorAll("[data-room]");
|
||||
var present = {};
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
present[containers[i].getAttribute("data-room")] = true;
|
||||
bindContainer(containers[i]);
|
||||
if (containers[i].__wireRoomKey) present[containers[i].__wireRoomKey] = true;
|
||||
}
|
||||
// Close rooms whose container has left the page (client-side navigation).
|
||||
for (var nm in open) if (!present[nm]) open[nm].close();
|
||||
|
||||
Reference in New Issue
Block a user