feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.30",
|
||||
"version": "0.8.44",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -236,7 +236,6 @@ function _functionEntry(
|
||||
: "";
|
||||
const stateAliases = stateNames.length ? `let { ${stateNames.join(", ")} } = context.state;` : "";
|
||||
const propAliases = propNames.length ? `const { ${propNames.join(", ")} } = context.props;` : "";
|
||||
const syncStateToContext = stateNames.map((name) => `context.state.${name} = ${name};`).join(" ");
|
||||
const syncStateFromContext = stateNames
|
||||
.map((name) => `${name} = context.state.${name};`)
|
||||
.join(" ");
|
||||
@@ -247,7 +246,12 @@ function _functionEntry(
|
||||
`const ${name} = (...__wrnexusPeerArgs) => context.functions[${JSON.stringify(name)}](...__wrnexusPeerArgs);`,
|
||||
)
|
||||
.join("\n")
|
||||
: `const __wrnexusFlush = () => { ${syncStateToContext} };
|
||||
: `const __wrnexusFlush = () => {
|
||||
const __wrnexusPendingState = { ${stateNames.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
|
||||
const __wrnexusApplyState = () => { ${stateNames.map((name) => `context.state.${name} = __wrnexusPendingState.${name};`).join(" ")} };
|
||||
if (typeof context.batch === "function") context.batch(__wrnexusApplyState);
|
||||
else __wrnexusApplyState();
|
||||
};
|
||||
const __wrnexusRestore = () => { ${syncStateFromContext} };
|
||||
const __wrnexusPeer = (name, args) => {
|
||||
__wrnexusFlush();
|
||||
@@ -474,19 +478,93 @@ export function generateBrowserModule(ast: PageAst): string {
|
||||
!sharedState.includes(name) &&
|
||||
!sharedProps.includes(name),
|
||||
);
|
||||
const sharedCommit = sharedState.map((name) => `context.state.${name} = ${name};`).join(" ");
|
||||
const sharedRestore = [
|
||||
...sharedState.map((name) => `${name} = context.state.${name};`),
|
||||
...sharedProps.map((name) => `${name} = context.props.${name};`),
|
||||
].join(" ");
|
||||
// A display helper must not subscribe to unrelated state just to synchronize
|
||||
// the closure. Conservatively include identifiers from called/returned peers
|
||||
// (including callbacks and parameter defaults) so indirect reads remain reactive.
|
||||
const referencesPeer = (source: string, name: string) => {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`(?:^|[^A-Za-z0-9_$.])${escaped}(?![A-Za-z0-9_$])`);
|
||||
// Permission strings such as "calendar.create" are not calls to create().
|
||||
// Keep template interpolations conservative; masking a whole template would
|
||||
// otherwise hide helper references inside ${...}.
|
||||
return (
|
||||
pattern.test(maskStringsAndComments(source)) ||
|
||||
(source.includes("${") && pattern.test(source))
|
||||
);
|
||||
};
|
||||
const functionSources = new Map(
|
||||
functions.map((fn) => [
|
||||
fn.name,
|
||||
fn.body + "\n" + fn.parameters.map((parameter) => parameter.default ?? "").join("\n"),
|
||||
]),
|
||||
);
|
||||
const restoredNames = new Map<string, string[]>();
|
||||
for (const fn of functions) {
|
||||
const visited = new Set<string>();
|
||||
const collect = (name: string): string => {
|
||||
if (visited.has(name)) return "";
|
||||
visited.add(name);
|
||||
const source = functionSources.get(name) ?? "";
|
||||
return (
|
||||
source +
|
||||
"\n" +
|
||||
functions
|
||||
.filter((peer) => referencesPeer(source, peer.name))
|
||||
.map((peer) => collect(peer.name))
|
||||
.join("\n")
|
||||
);
|
||||
};
|
||||
const source = collect(fn.name);
|
||||
restoredNames.set(
|
||||
fn.name,
|
||||
[...sharedState, ...sharedProps].filter((name) => identifierReferenced(source, name)),
|
||||
);
|
||||
}
|
||||
const sharedCommit = `const values = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
|
||||
const pending = {};
|
||||
for (const name of Object.keys(values)) {
|
||||
if (!Object.is(values[name], committed[name])) { pending[name] = values[name]; committed[name] = values[name]; }
|
||||
}
|
||||
if (Object.keys(pending).length === 0) return;
|
||||
const apply = () => { for (const name of Object.keys(pending)) context.state[name] = pending[name]; };
|
||||
if (typeof context.batch === "function") context.batch(apply); else apply();`;
|
||||
const restores = [...sharedState, ...sharedProps]
|
||||
.map(
|
||||
(name) =>
|
||||
`${JSON.stringify(name)}:()=>{${
|
||||
sharedState.includes(name)
|
||||
? `${name} = context.state.${name}; committed.${name} = ${name};`
|
||||
: `${name} = context.props.${name};`
|
||||
}}`,
|
||||
)
|
||||
.join(",\n");
|
||||
const restoreGroups: string[][] = [];
|
||||
const restoreKeys: Record<string, number> = {};
|
||||
for (const [name, keys] of restoredNames) {
|
||||
let index = restoreGroups.findIndex((group) => JSON.stringify(group) === JSON.stringify(keys));
|
||||
if (index < 0) {
|
||||
index = restoreGroups.length;
|
||||
restoreGroups.push(keys);
|
||||
}
|
||||
restoreKeys[name] = index;
|
||||
}
|
||||
|
||||
const hasAuthoredCommit = callableAliases.includes("commit");
|
||||
const hasAuthoredSetTimeout = callableAliases.includes("setTimeout");
|
||||
const implementations = functions
|
||||
.map((fn) => {
|
||||
const parameters = fn.parameters.map((parameter) => parameter.name).join(", ");
|
||||
const parameters = fn.parameters
|
||||
.map(
|
||||
(parameter) =>
|
||||
parameter.name +
|
||||
(parameter.default !== undefined
|
||||
? ` = (${eraseFunctionTypes(parameter.default)})`
|
||||
: ""),
|
||||
)
|
||||
.join(", ");
|
||||
return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(${parameters}) {
|
||||
try { ${eraseFunctionTypes(fn.body)} } finally { __wrnexusCommit(); }
|
||||
}`;
|
||||
${eraseFunctionTypes(fn.body)}
|
||||
}`;
|
||||
})
|
||||
.join(",\n");
|
||||
return `// generated WRNexusJS browser module for ${ast.name}
|
||||
@@ -510,8 +588,11 @@ function __wrnexusCreateClientFunctions(context) {
|
||||
const props = context.props;
|
||||
const refs = context.refs;
|
||||
${apiBindings(ast)}
|
||||
const committed = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
|
||||
const __wrnexusCommit = () => { ${sharedCommit} };
|
||||
const __wrnexusRestore = () => { ${sharedRestore} };
|
||||
const __wrnexusRestores = { ${restores} };
|
||||
const __wrnexusRestoreKeys = ${JSON.stringify(restoreKeys)};
|
||||
const __wrnexusRestoreGroups = ${JSON.stringify(restoreGroups)};
|
||||
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
||||
${
|
||||
!hasAuthoredSetTimeout
|
||||
@@ -523,12 +604,19 @@ function __wrnexusCreateClientFunctions(context) {
|
||||
const implementations = {
|
||||
${implementations}
|
||||
};
|
||||
${callableAliases.map((name) => `const ${name} = (...args) => implementations[${JSON.stringify(name)}](...args);`).join("\n ")}
|
||||
${callableAliases.map((name) => `const ${name} = (...args) => ${functions.find((fn) => fn.name === name)?.async ? `__wrnexusInvoke(implementations[${JSON.stringify(name)}], args)` : `implementations[${JSON.stringify(name)}](...args)`};`).join("\n ")}
|
||||
const __wrnexusInvoke = (fn, args) => {
|
||||
let result;
|
||||
try { result = fn(...args); } catch (error) { __wrnexusCommit(); throw error; }
|
||||
__wrnexusCommit();
|
||||
if (result && typeof result.then === "function") return Promise.resolve(result).finally(__wrnexusCommit);
|
||||
return result;
|
||||
};
|
||||
const functions = {};
|
||||
for (const name of Object.keys(implementations)) {
|
||||
functions[name] = (...args) => {
|
||||
__wrnexusRestore();
|
||||
return implementations[name](...args);
|
||||
for (const key of __wrnexusRestoreGroups[__wrnexusRestoreKeys[name]]) __wrnexusRestores[key]();
|
||||
return __wrnexusInvoke(implementations[name], args);
|
||||
};
|
||||
}
|
||||
return functions;
|
||||
|
||||
@@ -918,7 +918,10 @@ function renderNestedComponentInvocation(
|
||||
|
||||
if (
|
||||
!attr.value.includes("{") ||
|
||||
(!exprRefsState(attr.value, ctx.stateNames) && !exprRefsState(attr.value, ctx.propNames))
|
||||
// A bare callback reference is not a computed field value. Keep its
|
||||
// existing transport while subscribing helper calls to their reads.
|
||||
(wholeExpression && ctx.functionNames.has(wholeExpression.trim())) ||
|
||||
!exprRefsComponentReactiveValue(attr.value, ctx)
|
||||
) {
|
||||
return rendered;
|
||||
}
|
||||
@@ -944,6 +947,7 @@ function renderNestedComponentInvocation(
|
||||
|
||||
return (
|
||||
`<div data-component="${attrEscape(node.tag)}"` +
|
||||
serverLoopLocalsAttribute(ctx) +
|
||||
`${ctx.forwardRestAttrs ? "${__wrnSpreadAttrs(__attrs)}" : ""}` +
|
||||
`${attrs}>${inner}</div>`
|
||||
);
|
||||
@@ -2344,7 +2348,7 @@ function renderClientControlTemplate(nodes: ViewNode[]): string {
|
||||
}
|
||||
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
|
||||
return attribute.value.includes("{")
|
||||
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(
|
||||
? `${rendered} data-wrn-${componentTag ? "prop-bind" : "bind"}-${bindIndex++}="${attrEscape(
|
||||
JSON.stringify([name, attribute.value]),
|
||||
)}"`
|
||||
: rendered;
|
||||
@@ -2368,6 +2372,13 @@ function encodeClientControl(value: unknown): string {
|
||||
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
||||
let expression = "``";
|
||||
const clientBranches: string[] = [];
|
||||
let previousConditions = "false";
|
||||
const selectedConditions = node.branches.map((branch) => {
|
||||
const condition = branch.cond === null ? "true" : `(${ctx.resolveExpr(branch.cond)})`;
|
||||
const selected = `!(${previousConditions}) && ${condition}`;
|
||||
previousConditions += ` || ${condition}`;
|
||||
return selected;
|
||||
});
|
||||
|
||||
for (let index = node.branches.length - 1; index >= 0; index--) {
|
||||
const branch = node.branches[index]!;
|
||||
@@ -2375,7 +2386,7 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
||||
const bodyExpression = "`" + body + "`";
|
||||
|
||||
clientBranches.unshift(
|
||||
`{ cond: ${branch.cond === null ? "null" : JSON.stringify(branch.cond)}, body: ${bodyExpression} }`,
|
||||
`{ cond: ${branch.cond === null ? "null" : JSON.stringify(branch.cond)}, body: (${selectedConditions[index]}) ? ${bodyExpression} : ${JSON.stringify(renderClientControlTemplate(branch.body))} }`,
|
||||
);
|
||||
|
||||
expression =
|
||||
@@ -2384,10 +2395,17 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
||||
: `(${ctx.resolveExpr(branch.cond)}) ? __branches[${index}].body : ${expression}`;
|
||||
}
|
||||
|
||||
// Render all branches on the server into an inert, encoded definition.
|
||||
// A branch first selected in the browser therefore contains the real nested
|
||||
// component HTML and scope metadata, not a data-component placeholder.
|
||||
return '${(() => { const __branches = [' + clientBranches.join(",") + ']; return `<template data-wrn-if="${__wrnexusEncodeControl(__branches)}"></template>${' + expression + '}<template data-wrn-control-end></template>`; })()}';
|
||||
// Only evaluate the selected branch on the server. Inactive branches retain
|
||||
// browser templates so guarded values may remain null until state changes.
|
||||
return (
|
||||
"${(() => { const __branches = [" +
|
||||
clientBranches.join(",") +
|
||||
']; return `<template data-wrn-if="${__wrnexusEncodeControl(__branches)}"' +
|
||||
serverLoopLocalsAttribute(ctx) +
|
||||
"></template>${" +
|
||||
expression +
|
||||
"}<template data-wrn-control-end></template>`; })()}"
|
||||
);
|
||||
}
|
||||
|
||||
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
||||
@@ -2427,7 +2445,7 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
||||
empty: renderClientControlTemplate(node.empty),
|
||||
});
|
||||
|
||||
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
|
||||
return `<template data-wrn-each="${definition}"${serverLoopLocalsAttribute(ctx)}></template>${serverBody}<template data-wrn-control-end></template>`;
|
||||
}
|
||||
|
||||
function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
||||
@@ -3073,6 +3091,7 @@ function __wrnSpreadAttrs(value: unknown): string {
|
||||
// and prop bindings ride from the mount onto the
|
||||
// rendered child root so the mounting scope can bind them there.
|
||||
(lowerName.startsWith("data-wrn") &&
|
||||
lowerName !== "data-wrn-loop-locals" &&
|
||||
!lowerName.startsWith("data-wrn-out-") &&
|
||||
!lowerName.startsWith("data-wrn-prop-bind-"))
|
||||
) {
|
||||
|
||||
@@ -180,6 +180,7 @@ function __wrnSpreadAttrs(value: unknown): string {
|
||||
// and prop bindings ride from the mount onto the
|
||||
// rendered child root so the mounting scope can bind them there.
|
||||
(lowerName.startsWith("data-wrn") &&
|
||||
lowerName !== "data-wrn-loop-locals" &&
|
||||
!lowerName.startsWith("data-wrn-out-") &&
|
||||
!lowerName.startsWith("data-wrn-prop-bind-"))
|
||||
) {
|
||||
|
||||
@@ -1161,6 +1161,21 @@ test("page state passed to a component retains a parent-owned prop binding", ()
|
||||
expect(output).toContain("data-wrn-prop-bind-");
|
||||
expect(output).toContain("{count}");
|
||||
});
|
||||
test("nested component helper results retain reactive bindings", () => {
|
||||
const output = generate(
|
||||
parse(`component Signature {
|
||||
props { value = {} }
|
||||
functions {
|
||||
shared function currentName() { return value.name || '' }
|
||||
shared function currentConsent() { return !!value.consent }
|
||||
}
|
||||
view { <Input value="{currentName()}" /><Switch checked="{currentConsent()}" /> }
|
||||
}`),
|
||||
);
|
||||
expect(output).toContain("["value","{currentName()}"]");
|
||||
expect(output).toContain("["checked","{currentConsent()}"]");
|
||||
});
|
||||
|
||||
test("nested component props retain parent-owned reactive bindings", () => {
|
||||
const output = generate(
|
||||
parse(`component Parent {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { compile } from "../src/index.ts";
|
||||
test("component conditional templates retain enclosing loop locals for reactive reevaluation", async () => {
|
||||
const source = `component FormFields {
|
||||
props { pages = [{id:'page',fields:[{id:'name',hidden:false}]}] }
|
||||
state answers = {}
|
||||
view { {#each pages as page}<section>{#each page.fields as field}{#if !field.hidden}<Input name="{field.id}" @input="answers[field.id]=payload.value" />{/if}{/each}</section>{/each} }
|
||||
}`;
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
|
||||
compile(source, "FormFields.wrn").code,
|
||||
);
|
||||
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
|
||||
const html = mod.render({});
|
||||
const markers = [...html.matchAll(/<template([^>]+)>/g)].map((match) => match[1]!);
|
||||
const conditional = markers.find((marker) => marker.includes("data-wrn-if="))!;
|
||||
const locals = /data-wrn-loop-locals="([^"]+)"/.exec(conditional);
|
||||
expect(conditional?.trim().startsWith("data-wrn-if=")).toBe(true);
|
||||
expect(locals).not.toBeNull();
|
||||
const decoded = JSON.parse(Buffer.from(locals![1]!, "base64").toString());
|
||||
expect(decoded.field.id).toBe("name");
|
||||
expect(decoded.page.id).toBe("page");
|
||||
const nestedEach = markers.filter((marker) => marker.includes("data-wrn-each="))[1]!;
|
||||
const eachLocals = /data-wrn-loop-locals="([^"]+)"/.exec(nestedEach);
|
||||
expect(eachLocals).not.toBeNull();
|
||||
expect(JSON.parse(Buffer.from(eachLocals![1]!, "base64").toString()).page.id).toBe("page");
|
||||
const child = /<div data-component="Input"([^>]+)>/.exec(html)!;
|
||||
const childLocals = /data-wrn-loop-locals="([^"]+)"/.exec(child[1]!);
|
||||
expect(childLocals).not.toBeNull();
|
||||
expect(JSON.parse(Buffer.from(childLocals![1]!, "base64").toString()).field.id).toBe("name");
|
||||
const childCode = new Bun.Transpiler({ loader: "ts" }).transformSync(
|
||||
compile("component Child { view { <input /> } }", "Child.wrn").code,
|
||||
);
|
||||
const childModule = await import(
|
||||
"data:text/javascript;base64," + Buffer.from(childCode).toString("base64")
|
||||
);
|
||||
const renderedChild = childModule.render({
|
||||
"data-wrn-loop-locals": childLocals![1]!,
|
||||
"data-wrn-if": "forbidden",
|
||||
});
|
||||
expect(renderedChild).toContain('data-wrn-loop-locals="' + childLocals![1] + '"');
|
||||
expect(renderedChild).not.toContain('data-wrn-if="forbidden"');
|
||||
});
|
||||
@@ -7,7 +7,9 @@ test("deep conditional components render each branch once with bounded document
|
||||
let view = "<p>Leaf</p>";
|
||||
for (let i = 0; i < 12; i++) view = `{#if open}${view}{:else}<i>Closed</i>{/if}`;
|
||||
const source = `component Nested {\n state open = true\n view { ${view} }\n }`;
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(compile(source, "Nested.wrn").code);
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
|
||||
compile(source, "Nested.wrn").code,
|
||||
);
|
||||
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
|
||||
runWithRequestContext({} as never, () => {
|
||||
const body = mod.render({});
|
||||
@@ -15,6 +17,6 @@ test("deep conditional components render each branch once with bounded document
|
||||
expect(body).toContain("<p>Leaf</p>");
|
||||
expect(document.length).toBeLessThan(30000);
|
||||
const registry = JSON.parse(document.match(/data-wrn-controls[^>]*>(.*?)<\/script>/s)![1]!);
|
||||
expect(registry).toHaveLength(12);
|
||||
expect(registry.filter(Array.isArray)).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { generateBrowserModule } from "../src/client-codegen.ts";
|
||||
|
||||
const source = `component FunctionDefaults {
|
||||
props { timezone = "UTC" }
|
||||
state selected = ""
|
||||
functions {
|
||||
client function choose(section = "connections", hour = 9) { selected = section; return hour }
|
||||
shared function label(prefix = "event", suffix = prefix + " details", zone = timezone) { return [prefix, suffix, zone] }
|
||||
}
|
||||
view { <button @click="choose()">Choose</button> }
|
||||
}`;
|
||||
test("browser functions retain defaults, prior-argument references and prop references", () => {
|
||||
const module = generateBrowserModule(parse(source));
|
||||
const functions = new Function(
|
||||
module.replace(/\bexport\s+(?=const|function)/g, "") + ";return __wrnexusClientFunctions;",
|
||||
)();
|
||||
const context = {
|
||||
state: { selected: "" },
|
||||
props: { timezone: "Asia/Kolkata" },
|
||||
batch: (apply: () => void) => apply(),
|
||||
};
|
||||
expect(functions.choose(context)).toBe(9);
|
||||
expect(context.state.selected).toBe("connections");
|
||||
expect(functions.label(context)).toEqual(["event", "event details", "Asia/Kolkata"]);
|
||||
expect(functions.label(context, "meeting")).toEqual([
|
||||
"meeting",
|
||||
"meeting details",
|
||||
"Asia/Kolkata",
|
||||
]);
|
||||
expect(functions.choose(context, undefined, 0)).toBe(0);
|
||||
expect(functions.label(context, null, "explicit", "UTC")).toEqual([null, "explicit", "UTC"]);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { compile } from "../src/index.ts";
|
||||
|
||||
test("inactive conditional branches do not dereference null state during server rendering", async () => {
|
||||
const source = `component AddressReview {
|
||||
state review = null
|
||||
view { <h3>Address review</h3>{#if review}<p>{review.address}</p>{#if review.suppression.active}<button>Reactivate</button>{/if}{/if} }
|
||||
}`;
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
|
||||
compile(source, "AddressReview.wrn").code,
|
||||
);
|
||||
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
|
||||
expect(() => mod.render({})).not.toThrow();
|
||||
expect(mod.render({})).toContain("<h3>Address review</h3>");
|
||||
});
|
||||
|
||||
test("selected branches render state while inactive nested branches remain browser templates", async () => {
|
||||
const source = `component AddressReview {
|
||||
props { review = { address: 'review@example.test', suppression: null } }
|
||||
view { {#if review}<p>{review.address}</p>{#if review.suppression}<b>{review.suppression.reason}</b>{:else}<i>No block</i>{/if}{/if} }
|
||||
}`;
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
|
||||
compile(source, "ReviewedAddress.wrn").code,
|
||||
);
|
||||
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
|
||||
const html = mod.render({});
|
||||
expect(html).toContain('data-text="review.address">review@example.test</span>');
|
||||
expect(html).toContain("<i>No block</i>");
|
||||
expect(html).not.toContain("<b>null</b>");
|
||||
});
|
||||
|
||||
test("inactive branch child components retain parent prop bindings", async () => {
|
||||
const source = `component AddressReview {
|
||||
props { messages = { evidence: 'Evidence', reactivate: 'Reactivate' } }
|
||||
state review = null
|
||||
view { {#if review}<Input label="{messages.evidence}" /><Button label="{messages.reactivate}" />{/if} }
|
||||
}`;
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
|
||||
compile(source, "LocalizedReview.wrn").code,
|
||||
);
|
||||
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
|
||||
const html = mod.render({});
|
||||
const match = /data-wrn-if="([A-Za-z0-9+/=]+)"/.exec(html)!;
|
||||
const body = JSON.parse(Buffer.from(match[1]!, "base64").toString())[0].body;
|
||||
expect(body).toContain("data-wrn-prop-bind-0=");
|
||||
expect(body).not.toContain("data-wrn-bind-0=");
|
||||
expect(body).toContain("messages.evidence");
|
||||
expect(body).toContain("messages.reactivate");
|
||||
});
|
||||
@@ -225,3 +225,225 @@ test("generated client modules stay below decoded-size and duplication ratchets"
|
||||
expect(decoded).toBeLessThanOrEqual(13_000);
|
||||
expect(decoded / compressed).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
test("async completion commits sibling state atomically before reactive readers run", async () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component AtomicCompletion {
|
||||
state { busy: boolean = true; submitted: boolean = false }
|
||||
functions { shared function visible(){return !submitted} client async function finish(){await Promise.resolve();busy=false;submitted=true} }
|
||||
view { <p>{visible()}</p> }
|
||||
}`),
|
||||
);
|
||||
const executable = new Function(
|
||||
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
|
||||
)();
|
||||
let batching = false,
|
||||
reads = 0;
|
||||
const values = { busy: true, submitted: false };
|
||||
const state = new Proxy(values, {
|
||||
set(target, key, value) {
|
||||
const previous = Reflect.get(target, key);
|
||||
Reflect.set(target, key, value);
|
||||
if (!batching && previous !== value) {
|
||||
reads++;
|
||||
functions.visible();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const functions = executable.bindClientScope({
|
||||
state,
|
||||
props: {},
|
||||
output: {},
|
||||
server: {},
|
||||
refs: {},
|
||||
batch(callback: () => void) {
|
||||
batching = true;
|
||||
try {
|
||||
callback();
|
||||
} finally {
|
||||
batching = false;
|
||||
}
|
||||
reads++;
|
||||
},
|
||||
});
|
||||
await functions.finish();
|
||||
expect(values.busy).toBe(false);
|
||||
expect(values.submitted).toBe(true);
|
||||
expect(reads).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("display helpers subscribe only to their transitive inputs and never write unchanged state", () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component FocusedHelpers {
|
||||
state { events: string[] = []; title: string = ""; selected: string = "" }
|
||||
props { timezone: string = "UTC"; unrelated: string = "" }
|
||||
functions {
|
||||
shared function zone(){return timezone}
|
||||
shared function matches(item){return item===selected}
|
||||
shared function visible(){return events.filter(matches).map(item=>item+zone())}
|
||||
client function changeTitle(value){title=value}
|
||||
}
|
||||
view { <p>{visible()}</p> }
|
||||
}`),
|
||||
);
|
||||
const executable = new Function(
|
||||
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
|
||||
)();
|
||||
const reads: string[] = [],
|
||||
writes: string[] = [];
|
||||
const values = { events: ["chosen"], title: "draft", selected: "chosen" };
|
||||
const functions = executable.bindClientScope({
|
||||
state: new Proxy(values, {
|
||||
get(target, key) {
|
||||
reads.push(String(key));
|
||||
return Reflect.get(target, key);
|
||||
},
|
||||
set(target, key, value) {
|
||||
writes.push(String(key));
|
||||
return Reflect.set(target, key, value);
|
||||
},
|
||||
}),
|
||||
props: { timezone: "UTC", unrelated: "" },
|
||||
output: {},
|
||||
server: {},
|
||||
refs: {},
|
||||
});
|
||||
reads.length = 0;
|
||||
writes.length = 0;
|
||||
expect(functions.visible()).toEqual(["chosenUTC"]);
|
||||
expect([...new Set(reads)].sort()).toEqual(["events", "selected"]);
|
||||
expect(writes).toEqual([]);
|
||||
functions.changeTitle("new draft");
|
||||
expect(writes).toEqual(["title"]);
|
||||
expect(values.title).toBe("new draft");
|
||||
reads.length = 0;
|
||||
writes.length = 0;
|
||||
expect(functions.visible()).toEqual(["chosenUTC"]);
|
||||
expect(reads).not.toContain("title");
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("helper defaults, template expressions and returned callbacks retain state dependencies", () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component IndirectReads {
|
||||
state { selected: string = "A"; title: string = "draft"; irrelevant: number = 0 }
|
||||
functions { shared function value(input=selected){return input} shared function label(){return () => \`\${title}:\${value()}\`} }
|
||||
view { <p>{label()()}</p> }
|
||||
}`),
|
||||
);
|
||||
const executable = new Function(
|
||||
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
|
||||
)();
|
||||
const reads: string[] = [];
|
||||
const values = { selected: "A", title: "draft", irrelevant: 0 };
|
||||
const functions = executable.bindClientScope({
|
||||
state: new Proxy(values, {
|
||||
get(target, key) {
|
||||
reads.push(String(key));
|
||||
return Reflect.get(target, key);
|
||||
},
|
||||
}),
|
||||
props: {},
|
||||
output: {},
|
||||
server: {},
|
||||
refs: {},
|
||||
});
|
||||
reads.length = 0;
|
||||
expect(functions.label()()).toBe("draft:A");
|
||||
expect([...new Set(reads)].sort()).toEqual(["selected", "title"]);
|
||||
values.title = "updated";
|
||||
expect(functions.label()()).toBe("updated:A");
|
||||
});
|
||||
|
||||
test("a focused helper cannot overwrite unrelated state changed outside the bound closure", async () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component PreserveExternalState {
|
||||
state { count: number = 0; title: string = "draft" }
|
||||
functions { shared function countLabel(){return String(count)} client async function increment(){await Promise.resolve();count+=1} }
|
||||
view { <p>{countLabel()}</p> }
|
||||
}`),
|
||||
);
|
||||
const executable = new Function(
|
||||
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
|
||||
)();
|
||||
const state = { count: 0, title: "draft" };
|
||||
const functions = executable.bindClientScope({
|
||||
state,
|
||||
props: {},
|
||||
output: {},
|
||||
server: {},
|
||||
refs: {},
|
||||
});
|
||||
state.title = "external update";
|
||||
expect(functions.countLabel()).toBe("0");
|
||||
expect(state.title).toBe("external update");
|
||||
await functions.increment();
|
||||
expect(state.count).toBe(1);
|
||||
expect(state.title).toBe("external update");
|
||||
});
|
||||
|
||||
test("permission strings and method names do not create false helper dependencies", () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component PermissionHelpers {
|
||||
state { title: string = "draft" }
|
||||
props { roles: string[] = [] }
|
||||
functions { shared function canCreate(){return roles.includes('calendar.create')} client function create(){return title} }
|
||||
view { <p>{canCreate()}</p> }
|
||||
}`),
|
||||
);
|
||||
const executable = new Function(
|
||||
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
|
||||
)();
|
||||
const reads: string[] = [];
|
||||
const functions = executable.bindClientScope({
|
||||
state: new Proxy(
|
||||
{ title: "draft" },
|
||||
{
|
||||
get(target, key) {
|
||||
reads.push(String(key));
|
||||
return Reflect.get(target, key);
|
||||
},
|
||||
},
|
||||
),
|
||||
props: { roles: ["calendar.create"] },
|
||||
output: {},
|
||||
server: {},
|
||||
refs: {},
|
||||
});
|
||||
reads.length = 0;
|
||||
expect(functions.canCreate()).toBe(true);
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
test("async functions publish loading and navigation state before yielding", async () => {
|
||||
const targets = generateTargets(
|
||||
parse(`component ImmediateAsyncState {
|
||||
state { busy: boolean = false; selected: string = "old" }
|
||||
functions { shared function label(){return selected} client async function load(value){busy=true;selected=value;await server.wait();busy=false} }
|
||||
view { <p>{label()}</p> }
|
||||
}`),
|
||||
);
|
||||
const executable = new Function(
|
||||
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
|
||||
)();
|
||||
let release: any;
|
||||
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||
const state = { busy: false, selected: "old" };
|
||||
const functions = executable.bindClientScope({
|
||||
state,
|
||||
props: {},
|
||||
output: {},
|
||||
server: { wait: () => gate },
|
||||
refs: {},
|
||||
});
|
||||
const pending = functions.load("new");
|
||||
expect(state.busy).toBe(true);
|
||||
expect(state.selected).toBe("new");
|
||||
expect(functions.label()).toBe("new");
|
||||
expect(state.busy).toBe(true);
|
||||
release();
|
||||
await pending;
|
||||
expect(state.busy).toBe(false);
|
||||
expect(state.selected).toBe("new");
|
||||
});
|
||||
Reference in new issue
Block a user