release: WRNexusJS 0.8.0
This commit is contained in:
+44
-10
@@ -1,5 +1,34 @@
|
||||
# @wrnexus/csr
|
||||
|
||||
## Navigation state preservation
|
||||
|
||||
Pages can opt into restoration across client navigation:
|
||||
|
||||
```wrn
|
||||
page Users {
|
||||
navigation {
|
||||
preserve = ["filters", "pagination", "scroll", "tabs", "expanded"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Form-like categories restore named inputs, selects, and textareas. Password,
|
||||
file, hidden, CSRF/token/secret/credential fields, and elements marked
|
||||
`data-no-preserve` are never saved. For tab, expanded, or component UI state,
|
||||
mark stable elements with `data-wrn-preserve="key"`; their value and ARIA
|
||||
selected/expanded state are restored. State is scoped to pathname plus query.
|
||||
|
||||
## Typed server actions
|
||||
|
||||
`createActionClient<Input, Output>(route, name)` supports programmatic calls.
|
||||
Schema-backed WRN actions also export `__wrnexusActionClients`, whose input and
|
||||
output are inferred automatically. Enhanced forms expose
|
||||
`data-wrn-action-state="pending|success|error"` and dispatch bubbling
|
||||
`wrnexus:action:optimistic`, `:pending`, `:success`, and `:error` events.
|
||||
Success details contain returned data and invalidated cache tags; error details
|
||||
contain field errors. Without JavaScript, the same form posts to its page and
|
||||
receives a 303 redirect or accessible validation response.
|
||||
|
||||
> The browser-side client runtime for WrNexus — generic, self-contained JS that hydrates server-rendered pages with reactivity, client-side navigation, and realtime rooms.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
@@ -49,16 +78,21 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME
|
||||
|
||||
Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works.
|
||||
|
||||
| Directive | Purpose |
|
||||
| -------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
|
||||
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
|
||||
| `data-text="expr"` | Bind an element's `textContent` to an expression |
|
||||
| `data-show="expr"` | Toggle visibility (`display`) on truthiness |
|
||||
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder |
|
||||
| `data-key="item.id"` | Alternative key declaration for `data-for` templates |
|
||||
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
|
||||
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
|
||||
| Directive | Purpose |
|
||||
| ---------------------------------- | ---------------------------------------------------- |
|
||||
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
|
||||
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
|
||||
| `data-text="expr"` | Bind an element's `textContent` to an expression |
|
||||
| `data-show="expr"` | Toggle visibility while preserving interactive state |
|
||||
|
||||
Compiled conditional rendering and dynamic component cases omit inactive elements from the live
|
||||
DOM. `data-show` is a visibility directive for stateful controls and keeps its element mounted.
|
||||
Neither mechanism is authorization: never place secrets in client-rendered branches. Authorize on
|
||||
the server and return only data the current request may access.
|
||||
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder |
|
||||
| `data-key="item.id"` | Alternative key declaration for `data-for` templates |
|
||||
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
|
||||
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
|
||||
|
||||
Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export const ACTION_RUNTIME = String.raw`
|
||||
(function () {
|
||||
if (window.__wrnexusActionsInstalled) return;
|
||||
window.__wrnexusActionsInstalled = true;
|
||||
|
||||
function csrf() {
|
||||
var match = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
function detail(form, name, extra) {
|
||||
return Object.assign({ form: form, name: name }, extra || {});
|
||||
}
|
||||
|
||||
function emit(form, phase, name, extra, cancelable) {
|
||||
return form.dispatchEvent(new CustomEvent("wrnexus:action:" + phase, {
|
||||
bubbles: true,
|
||||
cancelable: !!cancelable,
|
||||
detail: detail(form, name, extra),
|
||||
}));
|
||||
}
|
||||
|
||||
document.addEventListener("submit", function (event) {
|
||||
var form = event.target && event.target.closest && event.target.closest("form[data-wrn-action]");
|
||||
if (!form || event.defaultPrevented) return;
|
||||
var name = form.getAttribute("data-wrn-action");
|
||||
if (!name) return;
|
||||
event.preventDefault();
|
||||
var data = new FormData(form);
|
||||
data.set("_wrnexus_action", name);
|
||||
data.set("_csrf", csrf());
|
||||
emit(form, "optimistic", name, { input: data }, true);
|
||||
form.setAttribute("aria-busy", "true");
|
||||
form.setAttribute("data-wrn-action-state", "pending");
|
||||
emit(form, "pending", name, { input: data });
|
||||
fetch(form.action || location.href, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
credentials: "same-origin",
|
||||
headers: { accept: "application/json", "x-wrnexus-action": name, "x-csrf-token": csrf() },
|
||||
}).then(async function (response) {
|
||||
var payload;
|
||||
try { payload = await response.json(); } catch (_) { payload = { error: await response.text() }; }
|
||||
if (!response.ok) throw Object.assign(new Error(payload.error || "Action failed"), { response: response, payload: payload });
|
||||
form.setAttribute("data-wrn-action-state", "success");
|
||||
emit(form, "success", name, { data: payload.data, invalidated: payload.invalidated || [] });
|
||||
if (payload.invalidated && payload.invalidated.length) {
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:cache:invalidate", { detail: { tags: payload.invalidated } }));
|
||||
}
|
||||
}).catch(function (error) {
|
||||
form.setAttribute("data-wrn-action-state", "error");
|
||||
emit(form, "error", name, { error: error, errors: error.payload && error.payload.errors });
|
||||
}).finally(function () {
|
||||
form.removeAttribute("aria-busy");
|
||||
});
|
||||
});
|
||||
})();
|
||||
`;
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface ActionClientOptions<I> {
|
||||
signal?: AbortSignal;
|
||||
csrfToken?: string;
|
||||
headers?: HeadersInit;
|
||||
serialize?: (input: I) => BodyInit;
|
||||
}
|
||||
|
||||
export interface ActionResult<O> {
|
||||
data: O;
|
||||
invalidated: string[];
|
||||
}
|
||||
|
||||
export class ActionClientError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly errors?: Record<string, string>,
|
||||
) {
|
||||
super(`Server action failed with status ${status}.`);
|
||||
this.name = "ActionClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createActionClient<I, O>(route: string, name: string) {
|
||||
return async (input: I, options: ActionClientOptions<I> = {}): Promise<ActionResult<O>> => {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("accept", "application/json");
|
||||
headers.set("x-wrnexus-action", name);
|
||||
if (options.csrfToken) headers.set("x-csrf-token", options.csrfToken);
|
||||
const body = options.serialize ? options.serialize(input) : JSON.stringify(input);
|
||||
if (!options.serialize) headers.set("content-type", "application/json");
|
||||
const response = await fetch(route, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
signal: options.signal,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
data?: O;
|
||||
invalidated?: string[];
|
||||
errors?: Record<string, string>;
|
||||
};
|
||||
if (!response.ok) throw new ActionClientError(response.status, payload.errors);
|
||||
return { data: payload.data as O, invalidated: payload.invalidated ?? [] };
|
||||
};
|
||||
}
|
||||
@@ -10,10 +10,12 @@
|
||||
import { REACTIVE_RUNTIME } from "./reactive-runtime.ts";
|
||||
import { NAV_RUNTIME } from "./nav-runtime.ts";
|
||||
import { REALTIME_RUNTIME } from "./realtime-runtime.ts";
|
||||
import { ACTION_RUNTIME } from "./action-runtime.ts";
|
||||
|
||||
export { REACTIVE_RUNTIME } from "./reactive-runtime.ts";
|
||||
export { NAV_RUNTIME } from "./nav-runtime.ts";
|
||||
export { REALTIME_RUNTIME } from "./realtime-runtime.ts";
|
||||
export { ACTION_RUNTIME } from "./action-runtime.ts";
|
||||
|
||||
/** The reactive runtime served at `/__wrnexus/reactive.js` (plain browser JS). */
|
||||
export function getReactiveRuntime(): string {
|
||||
@@ -30,8 +32,13 @@ export function getRealtimeRuntime(): string {
|
||||
return REALTIME_RUNTIME;
|
||||
}
|
||||
|
||||
export function getActionRuntime(): string {
|
||||
return ACTION_RUNTIME;
|
||||
}
|
||||
|
||||
export * from "./outputs.ts";
|
||||
export * from "./server-client.ts";
|
||||
export * from "./refs.ts";
|
||||
export * from "./client-functions.ts";
|
||||
export * from "./actions.ts";
|
||||
export type * from "./types.ts";
|
||||
|
||||
+189
-35
@@ -34,6 +34,138 @@ export const NAV_RUNTIME = String.raw`
|
||||
|
||||
var APP_ID = "app";
|
||||
var inFlight = null;
|
||||
var memory = Object.create(null);
|
||||
var keepAlive = Object.create(null);
|
||||
var keepAliveOrder = [];
|
||||
|
||||
function keepAliveKey(node) {
|
||||
return String(node && node.getAttribute("data-wrn-keepalive") || "");
|
||||
}
|
||||
|
||||
function retainKeepAlive(root) {
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-wrn-keepalive]"), function (node) {
|
||||
var key = keepAliveKey(node);
|
||||
if (!key) return;
|
||||
if (!keepAlive[key]) keepAliveOrder.push(key);
|
||||
keepAlive[key] = node;
|
||||
if (node.parentNode) node.parentNode.removeChild(node);
|
||||
});
|
||||
while (keepAliveOrder.length > 32) {
|
||||
var expired = keepAliveOrder.shift();
|
||||
if (expired && keepAlive[expired]) {
|
||||
dispose(keepAlive[expired]);
|
||||
delete keepAlive[expired];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function restoreKeepAlive(root) {
|
||||
var placeholders = [];
|
||||
if (root.matches && root.matches("[data-wrn-keepalive]")) placeholders.push(root);
|
||||
Array.prototype.forEach.call(root.querySelectorAll("[data-wrn-keepalive]"), function (node) { placeholders.push(node); });
|
||||
placeholders.forEach(function (placeholder) {
|
||||
var saved = keepAlive[keepAliveKey(placeholder)];
|
||||
if (saved && placeholder.parentNode) placeholder.parentNode.replaceChild(saved, placeholder);
|
||||
});
|
||||
}
|
||||
|
||||
function preservationPolicy(doc) {
|
||||
var meta = doc.querySelector('meta[name="wrnexus-preserve"]');
|
||||
var out = Object.create(null);
|
||||
String(meta && meta.getAttribute("content") || "").split(",").forEach(function (name) {
|
||||
if (name) out[name] = true;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncPreservationPolicy(nextDocument) {
|
||||
var selector = 'meta[name="wrnexus-preserve"]';
|
||||
var current = document.querySelector(selector);
|
||||
var next = nextDocument.querySelector(selector);
|
||||
if (!next) {
|
||||
if (current) current.remove();
|
||||
return;
|
||||
}
|
||||
if (!current) {
|
||||
current = document.createElement("meta");
|
||||
current.setAttribute("name", "wrnexus-preserve");
|
||||
document.head.appendChild(current);
|
||||
}
|
||||
current.setAttribute("content", next.getAttribute("content") || "");
|
||||
}
|
||||
|
||||
function stateKey(url) {
|
||||
try {
|
||||
var parsed = new URL(url, location.href);
|
||||
return "wrnexus:navigation:" + parsed.pathname + parsed.search;
|
||||
} catch (_) {
|
||||
return "wrnexus:navigation:" + String(url);
|
||||
}
|
||||
}
|
||||
|
||||
function safeField(field) {
|
||||
var type = String(field.type || "").toLowerCase();
|
||||
var name = String(field.name || field.id || "").toLowerCase();
|
||||
return type !== "password" && type !== "file" && type !== "hidden" &&
|
||||
!field.hasAttribute("data-no-preserve") &&
|
||||
!/(?:csrf|token|secret|password|credential)/.test(name);
|
||||
}
|
||||
|
||||
function capturePage(url) {
|
||||
var policy = preservationPolicy(document);
|
||||
var state = { fields: Object.create(null), elements: Object.create(null) };
|
||||
if (policy.scroll) state.scroll = [window.scrollX || 0, window.scrollY || 0];
|
||||
if (policy.forms || policy.filters || policy.pagination || policy.workflow) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll("#app input,#app select,#app textarea"), function (field, index) {
|
||||
if (!safeField(field)) return;
|
||||
var key = field.name || field.id || String(index);
|
||||
state.fields[key] = { value: field.value, checked: !!field.checked, selectedIndex: field.selectedIndex };
|
||||
});
|
||||
}
|
||||
if (policy.tabs || policy.expanded || policy.component) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll("#app [data-wrn-preserve]"), function (node, index) {
|
||||
var key = node.getAttribute("data-wrn-preserve") || node.id || String(index);
|
||||
state.elements[key] = {
|
||||
selected: node.getAttribute("aria-selected"),
|
||||
expanded: node.getAttribute("aria-expanded"),
|
||||
value: "value" in node ? node.value : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
memory[stateKey(url)] = state;
|
||||
try { sessionStorage.setItem(stateKey(url), JSON.stringify(state)); } catch (_) {}
|
||||
}
|
||||
|
||||
function restorePage(url, isPop) {
|
||||
var policy = preservationPolicy(document);
|
||||
var state = memory[stateKey(url)];
|
||||
if (!state) {
|
||||
try { state = JSON.parse(sessionStorage.getItem(stateKey(url)) || "null"); } catch (_) {}
|
||||
}
|
||||
if (state && (policy.forms || policy.filters || policy.pagination || policy.workflow)) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll("#app input,#app select,#app textarea"), function (field, index) {
|
||||
if (!safeField(field)) return;
|
||||
var saved = state.fields && state.fields[field.name || field.id || String(index)];
|
||||
if (!saved) return;
|
||||
if (field.type === "checkbox" || field.type === "radio") field.checked = !!saved.checked;
|
||||
else field.value = saved.value;
|
||||
if (field.tagName === "SELECT") field.selectedIndex = saved.selectedIndex;
|
||||
field.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
if (state && (policy.tabs || policy.expanded || policy.component)) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll("#app [data-wrn-preserve]"), function (node, index) {
|
||||
var key = node.getAttribute("data-wrn-preserve") || node.id || String(index);
|
||||
var saved = state.elements && state.elements[key];
|
||||
if (!saved) return;
|
||||
if (saved.selected != null) node.setAttribute("aria-selected", saved.selected);
|
||||
if (saved.expanded != null) node.setAttribute("aria-expanded", saved.expanded);
|
||||
if (saved.value != null && "value" in node) node.value = saved.value;
|
||||
});
|
||||
}
|
||||
if (state && policy.scroll && state.scroll) window.scrollTo(state.scroll[0], state.scroll[1]);
|
||||
else if (!isPop) window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function pathOf(src) {
|
||||
return String(src).split("?")[0];
|
||||
@@ -365,6 +497,8 @@ export const NAV_RUNTIME = String.raw`
|
||||
return;
|
||||
}
|
||||
|
||||
capturePage(location.href);
|
||||
|
||||
var incomingApp =
|
||||
doc.getElementById(APP_ID);
|
||||
|
||||
@@ -380,6 +514,8 @@ export const NAV_RUNTIME = String.raw`
|
||||
document.title = doc.title;
|
||||
}
|
||||
|
||||
syncPreservationPolicy(doc);
|
||||
|
||||
syncWrnStyles(doc);
|
||||
|
||||
var importedNodes = [];
|
||||
@@ -402,6 +538,7 @@ export const NAV_RUNTIME = String.raw`
|
||||
* connected. This is important for components that remove window or
|
||||
* document listeners during lifecycle.unmount.
|
||||
*/
|
||||
retainKeepAlive(currentApp);
|
||||
dispose(currentApp);
|
||||
|
||||
try {
|
||||
@@ -413,6 +550,7 @@ export const NAV_RUNTIME = String.raw`
|
||||
currentApp,
|
||||
importedNodes,
|
||||
);
|
||||
restoreKeepAlive(currentApp);
|
||||
} catch (_) {
|
||||
hardNavigate(url);
|
||||
return;
|
||||
@@ -439,9 +577,10 @@ export const NAV_RUNTIME = String.raw`
|
||||
url,
|
||||
);
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
restorePage(url, isPop);
|
||||
|
||||
dispatchNavigationEvent(url);
|
||||
}
|
||||
|
||||
@@ -449,50 +588,65 @@ export const NAV_RUNTIME = String.raw`
|
||||
var token = {};
|
||||
|
||||
inFlight = token;
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:navigation-start", { detail: { url: url } }));
|
||||
|
||||
fetch(url, {
|
||||
var pending = prefetched.get(url) || requestDocument(url, false);
|
||||
prefetched.delete(url);
|
||||
pending
|
||||
.then(function (result) {
|
||||
if (inFlight !== token) return null;
|
||||
if (result.redirectedUrl) url = result.redirectedUrl;
|
||||
if (result.contentType.indexOf("text/html") === -1) {
|
||||
hardNavigate(url);
|
||||
return null;
|
||||
}
|
||||
render(result.text, url, isPop);
|
||||
return null;
|
||||
})
|
||||
.catch(function () {
|
||||
if (inFlight === token) hardNavigate(url);
|
||||
});
|
||||
}
|
||||
|
||||
var prefetched = new Map();
|
||||
function requestDocument(url, isPrefetch) {
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
"x-wrnexus-nav": "1",
|
||||
...(isPrefetch ? { "x-wrnexus-prefetch": "1" } : {}),
|
||||
accept: "text/html",
|
||||
},
|
||||
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (response) {
|
||||
if (inFlight !== token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.redirected && response.url) {
|
||||
url = response.url;
|
||||
}
|
||||
|
||||
var contentType =
|
||||
response.headers.get("content-type") ||
|
||||
"";
|
||||
|
||||
if (
|
||||
contentType.indexOf("text/html") === -1
|
||||
) {
|
||||
hardNavigate(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.text().then(function (text) {
|
||||
if (inFlight !== token) {
|
||||
return;
|
||||
}
|
||||
|
||||
render(text, url, isPop);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
if (inFlight === token) {
|
||||
hardNavigate(url);
|
||||
}
|
||||
}).then(function (response) {
|
||||
var contentType = response.headers.get("content-type") || "";
|
||||
return response.text().then(function (text) {
|
||||
return {
|
||||
text: text,
|
||||
contentType: contentType,
|
||||
redirectedUrl: response.redirected && response.url ? response.url : "",
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function prefetch(anchor) {
|
||||
if (!isLocalLink(anchor) || anchor.hasAttribute("data-no-prefetch")) return;
|
||||
var url = anchor.href;
|
||||
if (url === location.href || prefetched.has(url)) return;
|
||||
prefetched.set(url, requestDocument(url, true));
|
||||
while (prefetched.size > 20) prefetched.delete(prefetched.keys().next().value);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerover", function (event) {
|
||||
var anchor = event.target && event.target.closest ? event.target.closest("a") : null;
|
||||
prefetch(anchor);
|
||||
}, { passive: true });
|
||||
document.addEventListener("focusin", function (event) {
|
||||
var anchor = event.target && event.target.closest ? event.target.closest("a") : null;
|
||||
prefetch(anchor);
|
||||
});
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (event) {
|
||||
|
||||
@@ -199,6 +199,17 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
? input[nextIndex]
|
||||
: "";
|
||||
|
||||
var currentLine = input
|
||||
.slice(start, previousIndex + 1)
|
||||
.trim();
|
||||
|
||||
// Postfix updates are complete statements. Treating their trailing + or
|
||||
// - as a multiline operator merges the following assignment into the
|
||||
// same statement (for example count++ followed by message = ...).
|
||||
if (/\+\+$|--$/.test(currentLine)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Newlines are formatting whitespace while either side is an
|
||||
// incomplete expression. This covers multiline assignments and
|
||||
// ternaries emitted by formatted .wrn component functions.
|
||||
@@ -1650,7 +1661,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
});
|
||||
});
|
||||
|
||||
// data-show="expr" — toggle visibility on truthiness.
|
||||
// data-show="expr" — toggle visibility on truthiness. This directive is
|
||||
// intentionally non-destructive because popovers, selects and remote data
|
||||
// controls keep event wiring and state while closed. Use a compiled {#if}
|
||||
// block when the inactive branch must not be rendered.
|
||||
el.querySelectorAll("[data-show]").forEach(function (node) {
|
||||
if (!owns(node)) return;
|
||||
|
||||
@@ -3651,6 +3665,56 @@ 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);
|
||||
var rendered = "";
|
||||
var cursor = 0;
|
||||
|
||||
while (cursor < template.length) {
|
||||
if (template[cursor] === "\\") {
|
||||
cursor++;
|
||||
var escaped = template[cursor++];
|
||||
rendered += escaped === "n" ? "\n" : escaped === "t" ? "\t" : escaped || "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (template[cursor] === "$" && template[cursor + 1] === "{") {
|
||||
var expressionStart = cursor + 2;
|
||||
var expressionEnd = expressionStart;
|
||||
var depth = 1;
|
||||
var quote = "";
|
||||
|
||||
for (; expressionEnd < template.length; expressionEnd++) {
|
||||
var character = template[expressionEnd];
|
||||
if (quote) {
|
||||
if (character === "\\") expressionEnd++;
|
||||
else if (character === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'") quote = character;
|
||||
else if (character === "{") depth++;
|
||||
else if (character === "}" && --depth === 0) break;
|
||||
}
|
||||
|
||||
if (depth !== 0) throw new Error("Unclosed template interpolation in '" + expr + "'");
|
||||
rendered += String(evaluateExpression(template.slice(expressionStart, expressionEnd), read));
|
||||
cursor = expressionEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
rendered += template[cursor++];
|
||||
}
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
var tokens =
|
||||
tokenizeExpression(expr);
|
||||
|
||||
@@ -3959,18 +4023,169 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
}
|
||||
|
||||
var asyncLoads = new Map();
|
||||
function asyncValueAt(value, path) {
|
||||
var current = value;
|
||||
var parts = String(path || "").split(".").filter(Boolean);
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (current == null || typeof current !== "object") return "";
|
||||
current = current[parts[i]];
|
||||
}
|
||||
return current == null ? "" : current;
|
||||
}
|
||||
function interpolateAsync(root, model, prefix) {
|
||||
function visit(node) {
|
||||
if (node.nodeType === 3) {
|
||||
node.textContent = String(node.textContent || "").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);
|
||||
});
|
||||
}
|
||||
Array.from(node.childNodes || []).forEach(visit);
|
||||
}
|
||||
visit(root);
|
||||
root.querySelectorAll("[data-wrn-async-value]").forEach(function (node) {
|
||||
var path = node.getAttribute("data-wrn-async-value") || "";
|
||||
var value = asyncValueAt(model, path);
|
||||
node.textContent = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
|
||||
});
|
||||
}
|
||||
function showAsync(boundary, state, model) {
|
||||
var template = boundary.querySelector("template[data-wrn-async-" + state + "]");
|
||||
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"));
|
||||
content.replaceChildren(fragment);
|
||||
boundary.setAttribute("aria-busy", state === "loading" ? "true" : "false");
|
||||
boundary.setAttribute("data-wrn-async-state", state);
|
||||
}
|
||||
function setupAsyncBoundary(boundary) {
|
||||
if (boundary.__wrnexusAsync) return;
|
||||
boundary.__wrnexusAsync = true;
|
||||
if (boundary.getAttribute("data-wrn-async-resolved") === "true") {
|
||||
boundary.setAttribute("data-wrn-async-state", "success");
|
||||
boundary.setAttribute("aria-busy", "false");
|
||||
return;
|
||||
}
|
||||
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 attempts = Math.min(5, Math.max(0, Number(boundary.getAttribute("data-wrn-async-retries")) || 0));
|
||||
var controller = new AbortController();
|
||||
boundary.__wrnexusAsyncAbort = controller;
|
||||
window.addEventListener("wrnexus:navigation-start", function () { controller.abort(); }, { once: true });
|
||||
function request(remaining) {
|
||||
var pending = asyncLoads.get(url);
|
||||
if (!pending) {
|
||||
pending = fetch(url, { headers: { accept: "application/json" }, signal: controller.signal }).then(function (response) {
|
||||
if (!response.ok) throw new Error("Client load returned " + response.status);
|
||||
return response.json();
|
||||
}).finally(function () { asyncLoads.delete(url); });
|
||||
asyncLoads.set(url, pending);
|
||||
}
|
||||
pending.then(function (result) {
|
||||
showAsync(boundary, "success", result.data);
|
||||
}).catch(function (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (remaining > 0) return setTimeout(function () { request(remaining - 1); }, 100 * (attempts - remaining + 1));
|
||||
showAsync(boundary, "error", { message: error && error.message ? error.message : "Loading failed" });
|
||||
});
|
||||
}
|
||||
showAsync(boundary, "loading", {});
|
||||
request(attempts);
|
||||
}
|
||||
function hydrateAsyncBoundaries(root) {
|
||||
(root || document).querySelectorAll("[data-wrn-async]").forEach(setupAsyncBoundary);
|
||||
}
|
||||
|
||||
function mountClientRoots(root) {
|
||||
(root || document).querySelectorAll("template[data-wrn-client-template]").forEach(function (template) {
|
||||
var id = template.getAttribute("data-wrn-client-template");
|
||||
var mount = null;
|
||||
(root || document).querySelectorAll("[data-wrn-client-root]").forEach(function (candidate) {
|
||||
if (!mount && candidate.getAttribute("data-wrn-client-root") === id) mount = candidate;
|
||||
});
|
||||
if (!mount || !template.content) return;
|
||||
var fragment = template.content.cloneNode(true);
|
||||
mount.replaceWith(fragment);
|
||||
template.remove();
|
||||
window.dispatchEvent(new window.CustomEvent("wrnexus:client-mounted", { detail: { id: id } }));
|
||||
});
|
||||
}
|
||||
|
||||
function hydrateDeclarativeUi(root) {
|
||||
var host = root || document;
|
||||
host.querySelectorAll("[data-wrn-portal]").forEach(function (portal) {
|
||||
if (portal.__wrnPortalMounted) return;
|
||||
var selector = portal.getAttribute("data-wrn-portal") || "body";
|
||||
var target = null; try { target = document.querySelector(selector); } catch (_) {}
|
||||
if (!target || target === portal || portal.contains(target)) return;
|
||||
portal.__wrnPortalMounted = true;
|
||||
var marker = document.createComment("wrnexus-portal");
|
||||
portal.parentNode && portal.parentNode.insertBefore(marker, portal);
|
||||
target.appendChild(portal);
|
||||
});
|
||||
host.querySelectorAll("[data-wrn-transition]").forEach(function (element) {
|
||||
if (element.__wrnTransitionMounted) return;
|
||||
element.__wrnTransitionMounted = true;
|
||||
var name = element.getAttribute("data-wrn-transition") || "wrn-transition";
|
||||
element.classList.add(name + "-enter", name + "-enter-active");
|
||||
requestAnimationFrame(function () { element.classList.remove(name + "-enter"); element.classList.add(name + "-enter-to"); });
|
||||
element.addEventListener("transitionend", function () { element.classList.remove(name + "-enter-active", name + "-enter-to"); }, { once: true });
|
||||
});
|
||||
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
||||
if (element.__wrnDynamicMounted) return;
|
||||
element.__wrnDynamicMounted = true;
|
||||
var cases = Array.prototype.slice.call(element.children).filter(function (candidate) {
|
||||
return candidate.hasAttribute("data-component-case");
|
||||
}).map(function (candidate) {
|
||||
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
||||
element.insertBefore(marker, candidate);
|
||||
return { candidate: candidate, marker: marker };
|
||||
});
|
||||
var update = function () {
|
||||
var selected = element.getAttribute("data-wrn-dynamic-component") || "";
|
||||
cases.forEach(function (record) {
|
||||
var candidate = record.candidate;
|
||||
var active = candidate.getAttribute("data-component-case") === selected;
|
||||
candidate.hidden = false;
|
||||
if (active && !candidate.isConnected && record.marker.parentNode) {
|
||||
record.marker.parentNode.insertBefore(candidate, record.marker.nextSibling);
|
||||
} else if (!active && candidate.parentNode) {
|
||||
candidate.parentNode.removeChild(candidate);
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(new window.CustomEvent("wrnexus:dynamic-component", { detail: { component: selected } }));
|
||||
};
|
||||
update();
|
||||
new MutationObserver(update).observe(element, { attributes: true, attributeFilter: ["data-wrn-dynamic-component"] });
|
||||
});
|
||||
}
|
||||
|
||||
window.__wrnexusMountClientRoots = mountClientRoots;
|
||||
window.__wrnexusHydrateAsyncBoundaries = hydrateAsyncBoundaries;
|
||||
window.__wrnexusHydrateScopes = hydrateScopes;
|
||||
window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); };
|
||||
window.__wrnexusHydrateCsrFetches = hydrateCsrFetches;
|
||||
window.__wrnexusDisposeBehaviors = disposeBehaviors;
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
mountClientRoots(document);
|
||||
hydrateScopes(document);
|
||||
hydrateCsrFetches(document);
|
||||
hydrateAsyncBoundaries(document);
|
||||
hydrateDeclarativeUi(document);
|
||||
});
|
||||
} else {
|
||||
mountClientRoots(document);
|
||||
hydrateScopes(document);
|
||||
hydrateCsrFetches(document);
|
||||
hydrateAsyncBoundaries(document);
|
||||
hydrateDeclarativeUi(document);
|
||||
}
|
||||
})();
|
||||
`.trim();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { ACTION_RUNTIME } from "../src/action-runtime.ts";
|
||||
import { createActionClient } from "../src/actions.ts";
|
||||
|
||||
const originalGlobals = new Map(
|
||||
["window", "document", "location", "CustomEvent", "FormData", "fetch"].map((key) => [
|
||||
key,
|
||||
(globalThis as Record<string, unknown>)[key],
|
||||
]),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ["window", "document", "location", "CustomEvent", "FormData", "fetch"]) {
|
||||
delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [key, value] of originalGlobals) {
|
||||
if (value === undefined) delete (globalThis as Record<string, unknown>)[key];
|
||||
else (globalThis as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
test("action runtime exposes pending, optimistic, success, and invalidation state", async () => {
|
||||
const win = new Window({ url: "https://example.test/users" });
|
||||
win.document.cookie = "wire-csrf=token";
|
||||
win.document.body.innerHTML = `<form data-wrn-action="createUser"><input name="name" value="Ada"></form>`;
|
||||
const phases: string[] = [];
|
||||
["optimistic", "pending", "success"].forEach((phase) =>
|
||||
win.document.addEventListener(`wrnexus:action:${phase}`, () => phases.push(phase)),
|
||||
);
|
||||
let invalidated: unknown;
|
||||
win.addEventListener("wrnexus:cache:invalidate", (event) => {
|
||||
invalidated = (event as unknown as CustomEvent).detail.tags;
|
||||
});
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
Object.assign(g, {
|
||||
window: win,
|
||||
document: win.document,
|
||||
location: win.location,
|
||||
CustomEvent: win.CustomEvent,
|
||||
FormData: win.FormData,
|
||||
fetch: async () => Response.json({ data: { id: 1 }, invalidated: ["users"] }),
|
||||
});
|
||||
(0, eval)(ACTION_RUNTIME);
|
||||
win.document
|
||||
.querySelector("form")!
|
||||
.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(phases).toEqual(["optimistic", "pending", "success"]);
|
||||
expect(invalidated).toEqual(["users"]);
|
||||
expect(win.document.querySelector("form")?.getAttribute("data-wrn-action-state")).toBe("success");
|
||||
});
|
||||
|
||||
test("typed action client returns invalidations and structured validation errors", async () => {
|
||||
const original = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = (async () =>
|
||||
Response.json({ data: { id: 1 }, invalidated: ["users"] })) as unknown as typeof fetch;
|
||||
const call = createActionClient<{ name: string }, { id: number }>("/users", "createUser");
|
||||
expect(await call({ name: "Ada" }, { csrfToken: "token" })).toEqual({
|
||||
data: { id: 1 },
|
||||
invalidated: ["users"],
|
||||
});
|
||||
globalThis.fetch = (async () =>
|
||||
Response.json({ errors: { name: "Required" } }, { status: 422 })) as unknown as typeof fetch;
|
||||
await expect(call({ name: "" })).rejects.toMatchObject({
|
||||
status: 422,
|
||||
errors: { name: "Required" },
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,7 @@ function install(bodyHtml: string): void {
|
||||
g.location = win.location;
|
||||
g.DOMParser = win.DOMParser;
|
||||
g.CustomEvent = win.CustomEvent;
|
||||
g.Event = win.Event;
|
||||
g.fetch = win.fetch = (url: string, opts: any) => {
|
||||
fetchCalls.push({ url, opts });
|
||||
return Promise.resolve({
|
||||
@@ -44,6 +45,7 @@ beforeEach(() => {
|
||||
"location",
|
||||
"DOMParser",
|
||||
"CustomEvent",
|
||||
"Event",
|
||||
"fetch",
|
||||
]) {
|
||||
delete g[k];
|
||||
@@ -64,6 +66,22 @@ test("intercepts an internal link click and swaps #app in place", async () => {
|
||||
expect(win.document.title).toBe("About");
|
||||
});
|
||||
|
||||
test("prefetches focused routes once and reuses the document during navigation", async () => {
|
||||
install(`<div id="app"><a href="/about" id="prefetch">About</a></div>`);
|
||||
nextHtml =
|
||||
`<!doctype html><html><head><title>About</title></head>` +
|
||||
`<body><div id="app"><h1>Prefetched</h1></div></body></html>`;
|
||||
const link = win.document.getElementById("prefetch");
|
||||
link.dispatchEvent(new win.FocusEvent("focusin", { bubbles: true }));
|
||||
await flush();
|
||||
expect(fetchCalls).toHaveLength(1);
|
||||
expect(fetchCalls[0]?.opts.headers["x-wrnexus-prefetch"]).toBe("1");
|
||||
link.click();
|
||||
await flush();
|
||||
expect(fetchCalls).toHaveLength(1);
|
||||
expect(win.document.querySelector("h1")?.textContent).toBe("Prefetched");
|
||||
});
|
||||
|
||||
test("ignores cross-origin links (full navigation)", async () => {
|
||||
install(`<div id="app"><a href="https://other.test/x" id="lnk">x</a></div>`);
|
||||
win.document.getElementById("lnk").click();
|
||||
@@ -151,3 +169,37 @@ test("synchronizes page styles during client navigation and preserves the curren
|
||||
expect(style.textContent).toContain("color:blue");
|
||||
expect(style.getAttribute("nonce")).toBe("current-nonce");
|
||||
});
|
||||
|
||||
test("restores declared form state but never preserves sensitive fields", async () => {
|
||||
install(
|
||||
`<meta name="wrnexus-preserve" content="forms">` +
|
||||
`<div id="app"><input name="query" value="draft"><input name="password" type="password" value="secret"></div>`,
|
||||
);
|
||||
nextHtml = `<html><head><meta name="wrnexus-preserve" content="forms"></head><body><div id="app">Next</div></body></html>`;
|
||||
win.__wrnexusNavigate("/next");
|
||||
await flush();
|
||||
|
||||
nextHtml =
|
||||
`<html><head><meta name="wrnexus-preserve" content="forms"></head><body><div id="app">` +
|
||||
`<input name="query" value=""><input name="password" type="password" value=""></div></body></html>`;
|
||||
win.__wrnexusNavigate("/");
|
||||
await flush();
|
||||
|
||||
expect(win.document.querySelector('[name="query"]').value).toBe("draft");
|
||||
expect(win.document.querySelector('[name="password"]').value).toBe("");
|
||||
});
|
||||
|
||||
test("KeepAlive preserves the same live DOM instance across routes", async () => {
|
||||
install(
|
||||
`<div id="app"><section data-wrn-keepalive="dashboard"><input value="live"></section></div>`,
|
||||
);
|
||||
const original = win.document.querySelector("[data-wrn-keepalive]");
|
||||
original.runtimeState = { count: 7 };
|
||||
nextHtml = `<html><body><div id="app"><section data-wrn-keepalive="dashboard"><input value="new"></section></div></body></html>`;
|
||||
win.__wrnexusNavigate("/next");
|
||||
await flush();
|
||||
const restored = win.document.querySelector("[data-wrn-keepalive]");
|
||||
expect(restored).toBe(original);
|
||||
expect(restored.runtimeState).toEqual({ count: 7 });
|
||||
expect(restored.querySelector("input").value).toBe("live");
|
||||
});
|
||||
|
||||
@@ -9,9 +9,13 @@ function mount(html: string): Window {
|
||||
win.document.body.innerHTML = `<div id="app">${html}</div>`;
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).location = win.location;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(globalThis as Record<string, unknown>).MutationObserver = (
|
||||
win as unknown as { MutationObserver: unknown }
|
||||
).MutationObserver;
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
|
||||
// test window may not fire). setupScope is idempotent, so this is safe.
|
||||
@@ -23,6 +27,9 @@ function mount(html: string): Window {
|
||||
beforeEach(() => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
delete (globalThis as Record<string, unknown>).document;
|
||||
delete (globalThis as Record<string, unknown>).location;
|
||||
delete (globalThis as Record<string, unknown>).fetch;
|
||||
delete (globalThis as Record<string, unknown>).MutationObserver;
|
||||
});
|
||||
|
||||
test("hydrates {expr} mustaches from data-scope", () => {
|
||||
@@ -30,6 +37,40 @@ test("hydrates {expr} mustaches from data-scope", () => {
|
||||
expect(win.document.querySelector("span")!.textContent).toBe("0, 0");
|
||||
});
|
||||
|
||||
test("mounts client-only templates before hydrating their scopes", () => {
|
||||
const win = mount(
|
||||
`<div data-wrn-client-root="client-page" aria-busy="true"></div>` +
|
||||
`<template data-wrn-client-template="client-page"><main data-scope="count: 2"><b>{count}</b></main></template>`,
|
||||
);
|
||||
const runtime = win as unknown as {
|
||||
__wrnexusMountClientRoots?: (root: unknown) => void;
|
||||
__wrnexusHydrateScopes?: (root: unknown) => void;
|
||||
};
|
||||
runtime.__wrnexusMountClientRoots?.(win.document);
|
||||
runtime.__wrnexusHydrateScopes?.(win.document);
|
||||
expect(win.document.querySelector("template")).toBeNull();
|
||||
expect(win.document.querySelector("main b")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
test("orchestrates named client loads and renders the success template", async () => {
|
||||
(globalThis as Record<string, unknown>).fetch = () =>
|
||||
Promise.resolve(Response.json({ data: { name: "Ada" } }));
|
||||
const win = mount(
|
||||
`<section data-wrn-async="users" data-wrn-async-retries="0" aria-busy="true">` +
|
||||
`<div data-wrn-async-content>Loading</div>` +
|
||||
`<template data-wrn-async-loading>Loading</template>` +
|
||||
`<template data-wrn-async-success><strong>{users.name}</strong></template>` +
|
||||
`<template data-wrn-async-error><b>{error.message}</b></template></section>`,
|
||||
);
|
||||
const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void };
|
||||
runtime.__wrnexusHydrateAsyncBoundaries?.(win.document);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(win.document.querySelector("section")?.getAttribute("data-wrn-async-state")).toBe(
|
||||
"success",
|
||||
);
|
||||
expect(win.document.querySelector("strong")?.textContent).toBe("Ada");
|
||||
});
|
||||
|
||||
test("@event (data-on-click) mutates a signal and re-renders", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="count: 0"><button data-on-click="count++">{count}</button></div>`,
|
||||
@@ -83,6 +124,26 @@ test("component functions support formatted multiline assignments and ternaries"
|
||||
expect(increment.textContent).toBe("0");
|
||||
});
|
||||
|
||||
test("component functions evaluate template literals without unsafe eval", () => {
|
||||
const behavior = Buffer.from(
|
||||
JSON.stringify({
|
||||
functions: `function increment() {
|
||||
count++
|
||||
message = \`Count is now \${count}.\`
|
||||
}`,
|
||||
watches: [],
|
||||
lifecycle: {},
|
||||
}),
|
||||
).toString("base64");
|
||||
const win = mount(
|
||||
`<div data-scope="count: 0, message: 'ready'" data-wrn-behavior="${behavior}">` +
|
||||
`<button data-on-click="increment()">{message}</button></div>`,
|
||||
);
|
||||
const button = win.document.querySelector("button")!;
|
||||
button.click();
|
||||
expect(button.textContent).toBe("Count is now 1.");
|
||||
});
|
||||
|
||||
test("declared component events emit through the generic $emit function", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="" data-wrn-events="complete">
|
||||
@@ -220,7 +281,7 @@ test("expression evaluator supports flatMap callbacks", () => {
|
||||
expect(win.document.querySelector("span")!.textContent).toBe("1,2,3");
|
||||
});
|
||||
|
||||
test("data-show toggles visibility on a reactive expression (tabs pattern)", () => {
|
||||
test("data-show toggles visibility without destroying interactive state", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="tab: 0">
|
||||
<button data-on-click="tab = 1">go</button>
|
||||
@@ -228,17 +289,37 @@ test("data-show toggles visibility on a reactive expression (tabs pattern)", ()
|
||||
<section id="b" data-show="tab === 1">B</section>
|
||||
</div>`,
|
||||
);
|
||||
const disp = (id: string) =>
|
||||
(win.document.getElementById(id) as unknown as HTMLElement).style.display;
|
||||
expect(disp("a")).toBe("");
|
||||
expect(disp("b")).toBe("none");
|
||||
expect(win.document.getElementById("a")!.getAttribute("data-show")).toBe("true");
|
||||
expect(win.document.getElementById("b")!.getAttribute("data-show")).toBe("false");
|
||||
const a = win.document.getElementById("a") as unknown as HTMLElement;
|
||||
const b = win.document.getElementById("b") as unknown as HTMLElement;
|
||||
expect(a.style.display).toBe("");
|
||||
expect(b.style.display).toBe("none");
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(disp("a")).toBe("none");
|
||||
expect(disp("b")).toBe("");
|
||||
expect(win.document.getElementById("a")!.getAttribute("data-show")).toBe("false");
|
||||
expect(win.document.getElementById("b")!.getAttribute("data-show")).toBe("true");
|
||||
expect(a.style.display).toBe("none");
|
||||
expect(b.style.display).toBe("");
|
||||
});
|
||||
|
||||
test("dynamic components remove inactive cases from the live DOM", async () => {
|
||||
const win = mount(
|
||||
`<div data-scope="active: 'Admin'">
|
||||
<button data-on-click="active = active === 'Admin' ? 'Guest' : 'Admin'">switch</button>
|
||||
<div data-wrn-dynamic-component="Admin" data-wrn-bind-0='["data-wrn-dynamic-component","{active}"]'>
|
||||
<section data-component-case="Admin">Administrator secret</section>
|
||||
<section data-component-case="Guest">Guest dashboard</section>
|
||||
</div>
|
||||
</div>`,
|
||||
);
|
||||
win.document.dispatchEvent(new win.Event("DOMContentLoaded"));
|
||||
expect(win.document.querySelector('[data-component-case="Admin"]')?.textContent).toContain(
|
||||
"Administrator",
|
||||
);
|
||||
expect(win.document.querySelector('[data-component-case="Guest"]')).toBeNull();
|
||||
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(win.document.querySelector('[data-component-case="Admin"]')).toBeNull();
|
||||
expect(win.document.querySelector('[data-component-case="Guest"]')?.textContent).toContain(
|
||||
"Guest",
|
||||
);
|
||||
});
|
||||
|
||||
test("reactive data attributes preserve explicit boolean strings", () => {
|
||||
|
||||
Reference in New Issue
Block a user