diff --git a/.publish/ai/package.json b/.publish/ai/package.json index 1b8f8489..8c12fc31 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "license": "MIT", diff --git a/.publish/authz/package.json b/.publish/authz/package.json index c6b96741..c32c98b4 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/authz — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/cli/package.json b/.publish/cli/package.json index 22b9f125..bc09ae28 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/cli — part of the WrNexus framework.", "license": "MIT", @@ -28,16 +28,16 @@ "wrnexus": "./dist/index.js" }, "dependencies": { - "@wrnexus/core": "^0.2.30", - "@wrnexus/router": "^0.2.30", - "@wrnexus/csr": "^0.2.30", - "@wrnexus/compiler": "^0.2.30", - "@wrnexus/styles": "^0.2.30", - "@wrnexus/dev-server": "^0.2.30", - "@wrnexus/ui": "^0.2.30", - "@wrnexus/validation": "^0.2.30", - "@wrnexus/i18n": "^0.2.30", - "@wrnexus/db": "^0.2.30" + "@wrnexus/core": "^0.2.31", + "@wrnexus/router": "^0.2.31", + "@wrnexus/csr": "^0.2.31", + "@wrnexus/compiler": "^0.2.31", + "@wrnexus/styles": "^0.2.31", + "@wrnexus/dev-server": "^0.2.31", + "@wrnexus/ui": "^0.2.31", + "@wrnexus/validation": "^0.2.31", + "@wrnexus/i18n": "^0.2.31", + "@wrnexus/db": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index 52525cf5..e5a5c01c 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/compiler — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/core/package.json b/.publish/core/package.json index 9d6a72d2..99291dae 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/core — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/csr/package.json b/.publish/csr/package.json index 18d8d7eb..1d740a6e 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.30" + "@wrnexus/core": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/db/package.json b/.publish/db/package.json index a4038caa..d2bb4a36 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/db — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index d7535bf6..ea690999 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", @@ -25,18 +25,18 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.30", - "@wrnexus/router": "^0.2.30", - "@wrnexus/ssr": "^0.2.30", - "@wrnexus/csr": "^0.2.30", - "@wrnexus/compiler": "^0.2.30", - "@wrnexus/styles": "^0.2.30", - "@wrnexus/ui": "^0.2.30", - "@wrnexus/validation": "^0.2.30", - "@wrnexus/i18n": "^0.2.30", - "@wrnexus/db": "^0.2.30", - "@wrnexus/pubsub": "^0.2.30", - "@wrnexus/uploader": "^0.2.30" + "@wrnexus/core": "^0.2.31", + "@wrnexus/router": "^0.2.31", + "@wrnexus/ssr": "^0.2.31", + "@wrnexus/csr": "^0.2.31", + "@wrnexus/compiler": "^0.2.31", + "@wrnexus/styles": "^0.2.31", + "@wrnexus/ui": "^0.2.31", + "@wrnexus/validation": "^0.2.31", + "@wrnexus/i18n": "^0.2.31", + "@wrnexus/db": "^0.2.31", + "@wrnexus/pubsub": "^0.2.31", + "@wrnexus/uploader": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index 2e77b39d..ddf393c2 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/encryption — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json index f61799ad..b5d5912b 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.30" + "@wrnexus/core": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index 286379d4..96ebfa1d 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/i18n — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.30" + "@wrnexus/core": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index b0dfd15b..fe9e1f9c 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/jwt — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index a9a35491..00c92765 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/native": "^0.2.30" + "@wrnexus/native": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/native/package.json b/.publish/native/package.json index 8fb55786..b29abc42 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/native — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index c4b01dce..e4368bb9 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/oauth — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index 698b072b..62def059 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/pubsub — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/queue/package.json b/.publish/queue/package.json index b77d179b..8cb6b62d 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/queue — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index 06d76545..11cb802b 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/reactive — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/router/package.json b/.publish/router/package.json index af7b04c7..51cdc407 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", @@ -21,8 +21,8 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.2.30", - "@wrnexus/core": "^0.2.30" + "@wrnexus/compiler": "^0.2.31", + "@wrnexus/core": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 147047c5..68c399b5 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.30" + "@wrnexus/core": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index b57e79d9..ad8391d8 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.2.30" + "@wrnexus/uploader": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/test/package.json b/.publish/test/package.json index 4a08082a..b3a55130 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/test — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index c6be9e36..59d3de43 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/tracking — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/ui/package.json b/.publish/ui/package.json index 752e7ae9..0861f921 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/ui — part of the WrNexus framework.", "license": "MIT", @@ -22,7 +22,7 @@ "./ui.css": "./ui.css" }, "dependencies": { - "@wrnexus/core": "^0.2.30" + "@wrnexus/core": "^0.2.31" }, "files": [ "dist", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index cf24d2f5..1a6414b5 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/uploader — part of the WrNexus framework.", "license": "MIT", @@ -21,7 +21,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.2.30" + "@wrnexus/core": "^0.2.31" }, "files": [ "dist" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index 8e2d3758..6e2ef57d 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "description": "@wrnexus/validation — part of the WrNexus framework.", "license": "MIT", diff --git a/packages/ai/package.json b/packages/ai/package.json index b119ebd8..182e5687 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", diff --git a/packages/authz/package.json b/packages/authz/package.json index eaa572b9..8ca553a8 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 781c7277..47e61186 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index e15020d9..47ccb7be 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -348,6 +348,15 @@ const MIGRATIONS: Migration[] = [ // No generated application files require automatic migration. }, }, + { + version: "0.2.31", + id: "lifecycle-improvements", + description: + "Adds component functions, lifecycle hooks, state watchers, optimized behavior cleanup, and CSR navigation lifecycle support.", + apply() { + // No generated application files require automatic migration. + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 363f0048..c99b5de1 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 0575cf4e..ddc9da00 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -78,8 +78,22 @@ function renderAttr(attr: Attr): string { } function eventAttribute(name: string): string { - if (name.startsWith("browser-")) return `data-on-wrnexus-browser-${name.slice(8)}`; - if (name.startsWith("mobile-")) return `data-on-wrnexus-mobile-${name.slice(7)}`; + if (name.startsWith("window:")) { + return `data-on-window-${name.slice("window:".length)}`; + } + + if (name.startsWith("document:")) { + return `data-on-document-${name.slice("document:".length)}`; + } + + if (name.startsWith("browser-")) { + return `data-on-wrnexus-browser-${name.slice(8)}`; + } + + if (name.startsWith("mobile-")) { + return `data-on-wrnexus-mobile-${name.slice(7)}`; + } + return `data-on-${name}`; } @@ -437,7 +451,9 @@ function renderNestedComponentInvocation( .filter((attr) => attr.name !== "data-component") .map((attr) => { if (attr.event) { - return ` ${eventAttribute(attr.name)}="${compileAttrValue(attr.value, ctx)}"`; + return ( + escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`) + ); } if (attr.boolean) { @@ -790,6 +806,19 @@ interface CompCtx { loopVars?: Set; } +interface ComponentBehavior { + functions: string; + lifecycle: { + mount?: string; + update?: string; + unmount?: string; + }; + watches: Array<{ + state: string; + body: string; + }>; +} + /** Parse a `data-for="item in list"` / `"item, i in list"` directive value. */ export function parseForExpr(value: string): { item: string; index?: string; list: string } | null { const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec( @@ -861,6 +890,44 @@ function escLit(s: string): string { return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); } +function componentBehavior(ast: PageAst): ComponentBehavior | null { + const functions = ast.functions + .map((body) => body.trim()) + .filter(Boolean) + .join("\n\n"); + + const lifecycle = { + ...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}), + + ...(ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {}), + + ...(ast.lifecycle.unmount?.trim() ? { unmount: ast.lifecycle.unmount.trim() } : {}), + }; + + const watches = ast.watches.map((watch) => ({ + state: watch.state, + body: watch.body.trim(), + })); + + if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) { + return null; + } + + return { + functions, + lifecycle, + watches, + }; +} + +function behaviorAttribute(behavior: ComponentBehavior | null): string { + if (!behavior) { + return ""; + } + + return ` data-wrn-behavior="${attrEscape(JSON.stringify(behavior))}"`; +} + const INTERP_RE = /\{([^{}]+)\}/g; function exprRefsState(expr: string, stateNames: Set): boolean { @@ -971,7 +1038,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string { .filter((a) => a.name !== "class" && !a.name.startsWith("class:")) .map((a) => { if (a.event) { - return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`; + return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`; } if (a.boolean) { @@ -1066,12 +1133,17 @@ function generateComponent(ast: PageAst): string { // A component needs a reactive scope only when it has state or event handlers. // Prop-driven text/attributes are baked server-side, so static components ship // no JavaScript at all. - const needsScope = ast.states.length > 0 || viewHasEvents(ast.view); + const behavior = componentBehavior(ast); + + const needsScope = ast.states.length > 0 || viewHasEvents(ast.view) || behavior !== null; + const scopeKeys = [ ...effectiveProps.map((prop) => prop.name), ...ast.states.map((state) => state.name), ]; + const behaviorAttr = behaviorAttribute(behavior); + const decls: string[] = []; for (const prop of effectiveProps) { decls.push( @@ -1083,7 +1155,7 @@ function generateComponent(ast: PageAst): string { } const returnExpr = needsScope - ? "`" + styleTag + '
' + viewCode + "
`" + ? "`" + styleTag + `
` + viewCode + "
`" : "`" + styleTag + viewCode + "`"; const scopeLine = @@ -1098,6 +1170,11 @@ function generateComponent(ast: PageAst): string { } else { out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`); } + + if (behavior) { + out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`); + } + out.push(`function __coerce(v: any, def: any): any { if (v === undefined || v === null) return def; if (typeof def === "number") return Number(v); diff --git a/packages/compiler/src/parser.ts b/packages/compiler/src/parser.ts index 38fbf65d..92784309 100644 --- a/packages/compiler/src/parser.ts +++ b/packages/compiler/src/parser.ts @@ -105,6 +105,19 @@ export interface ModeFunctionsBlock { body: string; } +export type LifecycleHookName = "mount" | "update" | "unmount"; + +export interface LifecycleBlock { + mount?: string; + update?: string; + unmount?: string; +} + +export interface WatchBlock { + state: string; + body: string; +} + export interface RealtimeHandler { event: string; args: string[]; @@ -141,6 +154,8 @@ export interface PageAst { functions: string[]; dataApis: DataApiBlock[]; modeFunctions: ModeFunctionsBlock[]; + lifecycle: LifecycleBlock; + watches: WatchBlock[]; apis: ApiBlock[]; realtimes: RealtimeBlock[]; } @@ -210,6 +225,8 @@ export function parse(source: string): PageAst { const functions: string[] = []; const dataApis: DataApiBlock[] = []; const modeFunctions: ModeFunctionsBlock[] = []; + const lifecycle: LifecycleBlock = {}; + const watches: WatchBlock[] = []; const apis: ApiBlock[] = []; const realtimes: RealtimeBlock[] = []; @@ -339,6 +356,52 @@ export function parse(source: string): PageAst { styles.push(lx.readBalancedBraces()); break; } + case "lifecycle": { + lx.next(); + expect("lbrace"); + + while (lx.peek().type !== "rbrace") { + const hook = lx.peek(); + + if (hook.type === "eof") { + throw new ParseError("Unexpected end of input inside lifecycle block"); + } + + if (hook.type !== "ident") { + throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`); + } + + if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") { + throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`); + } + + const hookName = hook.value as LifecycleHookName; + + lx.next(); + + if (lifecycle[hookName] !== undefined) { + throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`); + } + + lifecycle[hookName] = lx.readBalancedBraces(); + } + + expect("rbrace"); + break; + } + case "watch": { + lx.next(); + + const stateName = expect("ident").value; + const body = lx.readBalancedBraces(); + + watches.push({ + state: stateName, + body, + }); + + break; + } case "functions": { lx.next(); functions.push(lx.readBalancedBraces()); @@ -350,6 +413,14 @@ export function parse(source: string): PageAst { } expect("rbrace"); + const declaredStates = new Set(states.map((state) => state.name)); + + for (const watcher of watches) { + if (!declaredStates.has(watcher.state)) { + throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`); + } + } + return { type: "page", kind, @@ -363,6 +434,8 @@ export function parse(source: string): PageAst { functions, dataApis, modeFunctions, + lifecycle, + watches, apis, realtimes, }; diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts index 19875fde..03fee9de 100644 --- a/packages/compiler/test/compiler.test.ts +++ b/packages/compiler/test/compiler.test.ts @@ -243,3 +243,196 @@ layout AppLayout { expect(output).toContain("export function render"); }); +test("parses component lifecycle hooks", () => { + const ast = parse(` +component BackToTop { + state visible = false + + lifecycle { + mount { + visible = true + } + + update { + console.log(visible) + } + + unmount { + visible = false + } + } + + view { + + } +} +`); + + expect(ast.lifecycle.mount).toContain("visible = true"); + + expect(ast.lifecycle.update).toContain("console.log(visible)"); + + expect(ast.lifecycle.unmount).toContain("visible = false"); +}); +test("parses state watchers", () => { + const ast = parse(` +component Menu { + state open = false + + watch open { + console.log("Open changed", open) + } + + view { + + } +} +`); + + expect(ast.watches).toEqual([ + { + state: "open", + body: expect.stringContaining('console.log("Open changed", open)'), + }, + ]); +}); +test("rejects watching an undeclared state", () => { + expect(() => + parse(` +component Broken { + watch missing { + console.log(missing) + } + + view { +
Broken
+ } +} +`), + ).toThrow("Cannot watch undeclared state 'missing'"); +}); +test("rejects unknown lifecycle hooks", () => { + expect(() => + parse(` +component Broken { + lifecycle { + created { + console.log("invalid") + } + } + + view { +
Broken
+ } +} +`), + ).toThrow("Unknown lifecycle hook 'created'"); +}); +test("component functions compile into browser behavior metadata", () => { + const output = generate( + parse(` +component Counter { + state count = 0 + + functions { + function increment() { + count += 1 + } + } + + view { + + } +} +`), + ); + + expect(output).toContain("data-wrn-behavior"); + + expect(output).toContain("function increment()"); + + expect(output).toContain("export const __wrnexusBehavior"); +}); +test("component lifecycle hooks compile into behavior metadata", () => { + const output = generate( + parse(` +component LifecycleTest { + state ready = false + + lifecycle { + mount { + ready = true + } + + update { + console.log(ready) + } + + unmount { + ready = false + } + } + + view { +
{ready}
+ } +} +`), + ); + + expect(output).toContain(""mount""); + + expect(output).toContain(""update""); + + expect(output).toContain(""unmount""); +}); +test("component watchers compile into behavior metadata", () => { + const output = generate( + parse(` +component WatchTest { + state visible = false + + watch visible { + console.log("changed", visible) + } + + view { +
{visible}
+ } +} +`), + ); + + expect(output).toContain(""state":"visible""); + + expect(output).toContain("console.log"); +}); +test("behavior-only components still receive a reactive scope", () => { + const output = generate( + parse(` +component BehaviorOnly { + functions { + function sayHello() { + console.log("hello") + } + } + + lifecycle { + mount { + sayHello() + } + } + + view { +
Hello
+ } +} +`), + ); + + expect(output).toContain("data-scope"); + + expect(output).toContain("data-wrn-behavior"); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 0a518a6a..4abf7cb0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/package.json b/packages/csr/package.json index 22c11e45..c113a266 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/nav-runtime.ts b/packages/csr/src/nav-runtime.ts index 00b5d3c6..57412f3b 100644 --- a/packages/csr/src/nav-runtime.ts +++ b/packages/csr/src/nav-runtime.ts @@ -4,127 +4,421 @@ * Progressive enhancement over normal links: intercepts same-origin `` * clicks, fetches the target page's HTML, swaps the `#app` container in place, * updates history/title/scroll, ensures any framework runtimes the new page - * needs are present, and re-hydrates. Anything unexpected (cross-origin, - * modified click, non-HTML response, missing `#app`) falls back to a full - * browser navigation, so behaviour degrades safely. + * needs are present, and re-hydrates. * - * Data "loaders": pages load their data on the server (SSR `api` bindings), so - * the fetched HTML already contains fresh data — no separate client loader is - * needed. Client-side (`csr`) bindings and reactive scopes re-hydrate after the - * swap. Programmatic navigation is exposed as `window.__wrnexusNavigate(url)`. + * Before replacing the current page, component lifecycle behaviors are + * explicitly disposed. This ensures `unmount` hooks and watcher cleanups run + * before the old DOM is removed. + * + * Programmatic navigation is exposed as: + * + * window.__wrnexusNavigate(url) */ export const NAV_RUNTIME = String.raw` (function () { - if (!window.history || !history.pushState || !window.fetch || !window.DOMParser) return; - if (window.__wrnexusNavInstalled) return; + if ( + !window.history || + !history.pushState || + !window.fetch || + !window.DOMParser + ) { + return; + } + + if (window.__wrnexusNavInstalled) { + return; + } + window.__wrnexusNavInstalled = true; var APP_ID = "app"; + var inFlight = null; - function pathOf(src) { return String(src).split("?")[0]; } + function pathOf(src) { + return String(src).split("?")[0]; + } + + function isLocalLink(anchor) { + if ( + !anchor || + anchor.hasAttribute("download") || + anchor.hasAttribute("data-no-nav") + ) { + return false; + } + + if (anchor.target && anchor.target !== "_self") { + return false; + } + + if (anchor.origin !== location.origin) { + return false; + } + + var href = anchor.getAttribute("href"); + + if (!href || href.charAt(0) === "#") { + return false; + } + + var rel = ( + anchor.getAttribute("rel") || "" + ).toLowerCase(); - function isLocalLink(a) { - if (!a || a.hasAttribute("download") || a.hasAttribute("data-no-nav")) return false; - if (a.target && a.target !== "_self") return false; - if (a.origin !== location.origin) return false; - var href = a.getAttribute("href"); - if (!href || href.charAt(0) === "#") return false; - var rel = (a.getAttribute("rel") || "").toLowerCase(); return rel.indexOf("external") === -1; } function loadedScriptPaths() { - var set = {}; - document.querySelectorAll("script[src]").forEach(function (s) { - var src = s.getAttribute("src"); - if (src) set[pathOf(src)] = true; - }); - return set; + var loaded = {}; + + document + .querySelectorAll("script[src]") + .forEach(function (script) { + var src = script.getAttribute("src"); + + if (src) { + loaded[pathOf(src)] = true; + } + }); + + return loaded; } - // Append any /__wrnexus/* runtime the incoming page declares but the current - // document has not loaded yet. Fresh scripts self-initialise on load. + /** + * Append framework runtimes declared by the incoming document but not + * currently loaded. + */ function ensureScripts(doc) { var loaded = loadedScriptPaths(); - doc.querySelectorAll("script[src]").forEach(function (s) { - var src = s.getAttribute("src"); - if (!src || loaded[pathOf(src)]) return; - loaded[pathOf(src)] = true; - var el = document.createElement("script"); - el.src = src; - el.async = false; // preserve execution order (e.g. schemas.js before validate.js) - document.body.appendChild(el); - }); + + doc + .querySelectorAll("script[src]") + .forEach(function (script) { + var src = script.getAttribute("src"); + + if (!src || loaded[pathOf(src)]) { + return; + } + + loaded[pathOf(src)] = true; + + var element = + document.createElement("script"); + + element.src = src; + element.async = false; + + document.body.appendChild(element); + }); } - // Re-hydrate already-loaded runtimes against the swapped-in DOM. Every - // entrypoint is idempotent, so this is safe even when a fresh script also - // self-initialises the same nodes. - function rehydrate() { - try { if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document); } catch (e) {} - try { if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(document); } catch (e) {} - try { if (window.__wireValidate) window.__wireValidate.init(document); } catch (e) {} - try { if (window.wireTheme) window.wireTheme.bind(document); } catch (e) {} + /** + * Run explicit component cleanup before removing the existing page. + * + * The reactive runtime also observes removed DOM nodes, but explicit + * disposal here guarantees that unmount hooks run before replacement. + */ + function dispose(root) { + try { + if ( + typeof window.__wrnexusDisposeBehaviors === + "function" + ) { + window.__wrnexusDisposeBehaviors(root); + } + } catch (error) { + console.error( + "[wrnexus] failed to dispose page behaviors", + error, + ); + } + } + + /** + * Re-hydrate already-loaded runtimes against newly inserted DOM. + * + * All framework hydration functions must remain idempotent. + */ + function rehydrate(root) { + try { + if ( + typeof window.__wrnexusHydrateScopes === + "function" + ) { + window.__wrnexusHydrateScopes(root); + } + } catch (error) { + console.error( + "[wrnexus] failed to hydrate reactive scopes", + error, + ); + } + + try { + if ( + typeof window.__wrnexusHydrateCsrFetches === + "function" + ) { + window.__wrnexusHydrateCsrFetches(root); + } + } catch (error) { + console.error( + "[wrnexus] failed to hydrate CSR bindings", + error, + ); + } + + try { + if ( + window.__wireValidate && + typeof window.__wireValidate.init === + "function" + ) { + window.__wireValidate.init(document); + } + } catch (error) { + console.error( + "[wrnexus] failed to hydrate validation", + error, + ); + } + + try { + if ( + window.wireTheme && + typeof window.wireTheme.bind === + "function" + ) { + window.wireTheme.bind(document); + } + } catch (error) { + console.error( + "[wrnexus] failed to hydrate theme bindings", + error, + ); + } + } + + function dispatchNavigationEvent(url) { + try { + window.dispatchEvent( + new CustomEvent("wrnexus:navigated", { + detail: { + url: url, + }, + }), + ); + } catch (_) { + // CustomEvent may not be available in very old browsers. + } + } + + function hardNavigate(url) { + location.href = url; } function render(html, url, isPop) { - var doc = new DOMParser().parseFromString(html, "text/html"); - var to = doc.getElementById(APP_ID); - var from = document.getElementById(APP_ID); - if (!to || !from) { location.href = url; return; } // structure mismatch → hard nav - if (doc.title) document.title = doc.title; - // Swap #app by importing nodes — NOT innerHTML — so it works under a strict - // Trusted-Types CSP (require-trusted-types-for 'script'). - var imported = []; - for (var i = 0; i < to.childNodes.length; i++) imported.push(document.importNode(to.childNodes[i], true)); - from.replaceChildren.apply(from, imported); - ensureScripts(doc); - rehydrate(); - if (!isPop) { history.pushState({ wrnexusNav: true }, "", url); window.scrollTo(0, 0); } - try { - window.dispatchEvent(new CustomEvent("wrnexus:navigated", { detail: { url: url } })); - } catch (e) {} - } + var doc; - var inFlight = null; + try { + doc = new DOMParser().parseFromString( + html, + "text/html", + ); + } catch (_) { + hardNavigate(url); + return; + } + + var incomingApp = + doc.getElementById(APP_ID); + + var currentApp = + document.getElementById(APP_ID); + + if (!incomingApp || !currentApp) { + hardNavigate(url); + return; + } + + if (doc.title) { + document.title = doc.title; + } + + var importedNodes = []; + + for ( + var index = 0; + index < incomingApp.childNodes.length; + index++ + ) { + importedNodes.push( + document.importNode( + incomingApp.childNodes[index], + true, + ), + ); + } + + /* + * Run unmount hooks and watcher cleanup while the current DOM is still + * connected. This is important for components that remove window or + * document listeners during lifecycle.unmount. + */ + dispose(currentApp); + + try { + /* + * Avoid innerHTML so navigation works with strict Trusted Types and CSP + * configurations. + */ + currentApp.replaceChildren.apply( + currentApp, + importedNodes, + ); + } catch (_) { + hardNavigate(url); + return; + } + + /* + * Load any runtime required only by the incoming page. Fresh scripts + * initialise themselves when loaded. + */ + ensureScripts(doc); + + /* + * Hydrate runtimes that are already available. Hydration is scoped to + * #app instead of the entire document for less DOM traversal. + */ + rehydrate(currentApp); + + if (!isPop) { + history.pushState( + { + wrnexusNav: true, + }, + "", + url, + ); + + window.scrollTo(0, 0); + } + + dispatchNavigationEvent(url); + } function navigate(url, isPop) { var token = {}; + inFlight = token; - fetch(url, { headers: { "x-wrnexus-nav": "1", accept: "text/html" }, credentials: "same-origin" }) - .then(function (r) { - if (inFlight !== token) return null; // superseded by a newer navigation - if (r.redirected && r.url) url = r.url; // follow server redirects (e.g. auth) - var ct = r.headers.get("content-type") || ""; - if (ct.indexOf("text/html") === -1) { location.href = url; return null; } - return r.text().then(function (t) { - if (inFlight === token) render(t, url, isPop); + + fetch(url, { + headers: { + "x-wrnexus-nav": "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; + } + + 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 () { location.href = url; }); + .catch(function () { + if (inFlight === token) { + hardNavigate(url); + } + }); } document.addEventListener( "click", - function (e) { - if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; - var a = e.target && e.target.closest ? e.target.closest("a") : null; - if (!isLocalLink(a)) return; - if (a.href === location.href) { e.preventDefault(); return; } - e.preventDefault(); - navigate(a.href, false); + function (event) { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + + var anchor = + event.target && + event.target.closest + ? event.target.closest("a") + : null; + + if (!isLocalLink(anchor)) { + return; + } + + if (anchor.href === location.href) { + event.preventDefault(); + return; + } + + event.preventDefault(); + + navigate(anchor.href, false); }, false, ); - window.addEventListener("popstate", function () { - navigate(location.href, true); - }); + window.addEventListener( + "popstate", + function () { + navigate(location.href, true); + }, + ); - // Programmatic navigation for forms/actions and app code. + /** + * Programmatic navigation for forms, actions and application code. + */ window.__wrnexusNavigate = function (url) { - navigate(new URL(url, location.href).href, false); + navigate( + new URL(url, location.href).href, + false, + ); }; })(); `.trim(); diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 21819861..872e47de 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -15,8 +15,109 @@ * Expressions are evaluated by a tiny parser instead of `eval`/`new Function`, * so production can use a strong CSP without `unsafe-eval`. */ + export const REACTIVE_RUNTIME = String.raw` (function () { + var behaviorInstances = new WeakMap(); + var mountedBehaviorRoots = new Set(); + var pendingUpdateHooks = new Map(); + var updateHooksScheduled = false; + var behaviorObserver; + + function scheduleUpdateHook(element, callback) { + pendingUpdateHooks.set(element, callback); + if (updateHooksScheduled) return; + updateHooksScheduled = true; + queueMicrotask(function () { + updateHooksScheduled = false; + var pending = Array.from(pendingUpdateHooks.entries()); + pendingUpdateHooks.clear(); + pending.forEach(function (entry) { + var root = entry[0]; + var hook = entry[1]; + if (!root.isConnected) return; + try { hook(); } catch (error) { + console.error("[wrnexus] component update hook failed", error); + } + }); + }); + } + + function parseBehavior(element) { + var raw = element.getAttribute("data-wrn-behavior"); + if (!raw) return null; + try { + var parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch (error) { + console.error("[wrnexus] failed to parse component behavior", error); + return null; + } + } + + function extractBehaviorFunctions(source) { + var functions = []; + var re = /(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*\{/g; + var match; + while ((match = re.exec(source || ""))) { + var start = re.lastIndex; + var depth = 1; + var quote = ""; + var i = start; + for (; i < source.length; i++) { + var ch = source[i]; + if (quote) { + if (ch === "\\") i++; + else if (ch === quote) quote = ""; + continue; + } + if (ch === '"' || ch === "'" || ch === "\`") { + quote = ch; + continue; + } + if (ch === "{") depth++; + else if (ch === "}" && --depth === 0) break; + } + if (depth !== 0) break; + functions.push({ + name: match[1], + args: match[2].split(",").map(function (arg) { return arg.trim(); }).filter(Boolean), + body: source.slice(start, i), + }); + re.lastIndex = i + 1; + } + return functions; + } + + function splitStatements(input) { + var parts = []; + var start = 0; + var depth = 0; + var quote = ""; + for (var i = 0; i < input.length; i++) { + var ch = input[i]; + if (quote) { + if (ch === "\\") i++; + else if (ch === quote) quote = ""; + continue; + } + if (ch === '"' || ch === "'" || ch === "\`") { + quote = ch; + continue; + } + if (ch === "(" || ch === "[" || ch === "{") depth++; + else if (ch === ")" || ch === "]" || ch === "}") depth--; + else if ((ch === ";" || ch === "\n" || ch === "\r") && depth === 0) { + var part = input.slice(start, i).trim(); + if (part) parts.push(part); + start = i + 1; + } + } + var tail = input.slice(start).trim(); + if (tail) parts.push(tail); + return parts; + } + function signal(initial) { var value = initial; var subs = new Set(); @@ -24,8 +125,9 @@ export const REACTIVE_RUNTIME = String.raw` get: function () { return value; }, set: function (v) { if (Object.is(v, value)) return; + var previous = value; value = v; - subs.forEach(function (f) { f(value); }); + subs.forEach(function (f) { f(value, previous); }); }, subscribe: function (f) { subs.add(f); return function () { subs.delete(f); }; } }; @@ -58,29 +160,117 @@ export const REACTIVE_RUNTIME = String.raw` } function renderAll() { renderers.forEach(function (f) { f(); }); } - function readScope(name) { - var sig = signals[name]; - if (!sig) return undefined; - if (currentRenderer) sig.subscribe(currentRenderer); // track dependency - return sig.get(); - } - function peekScope(name) { - return signals[name] ? signals[name].get() : undefined; + var behaviorFunctions = {}; + var stateWatchers = {}; + var anyStateListeners = new Set(); + var cleanupCallbacks = []; + var disposed = false; + + function readGlobal(name) { + if (name === "window") return window; + if (name === "document") return document; + if (name === "console") return console; + if (name === "Math") return Math; + if (name === "JSON") return JSON; + if (name === "Date") return Date; + if (name === "URL") return URL; + if (name === "location") return window.location; + if (name === "history") return window.history; + if (name === "navigator") return window.navigator; + if (name === "setTimeout") return window.setTimeout.bind(window); + if (name === "clearTimeout") return window.clearTimeout.bind(window); + if (name === "requestAnimationFrame") return window.requestAnimationFrame.bind(window); + if (name === "cancelAnimationFrame") return window.cancelAnimationFrame.bind(window); + return undefined; } - function evalExpr(expr) { - return evaluateExpression(expr, readScope); + function readScope(name) { + var sig = signals[name]; + if (sig) { + if (currentRenderer) sig.subscribe(currentRenderer); + return sig.get(); + } + if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) { + return behaviorFunctions[name]; + } + return readGlobal(name); } - function runStmt(stmt) { - splitTopLevel(stmt, ";").forEach(function (part) { - runStatement(part, function (e) { return evaluateExpression(e, peekScope); }, peekScope, function (name, value) { - if (!signals[name]) { - signals[name] = signal(value); - renderAll(); // new variable: re-run once so readers pick it up + re-track - } else { - signals[name].set(value); + + function peekScope(name) { + if (signals[name]) return signals[name].get(); + if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) { + return behaviorFunctions[name]; + } + return readGlobal(name); + } + + function notifyState(name, value, previous) { + var watchers = stateWatchers[name]; + if (watchers) { + watchers.slice().forEach(function (watcher) { + try { watcher(value, previous); } catch (error) { + console.error("[wrnexus] watcher for '" + name + "' failed", error); } }); + } + anyStateListeners.forEach(function (listener) { + try { listener(name, value, previous); } catch (error) { + console.error("[wrnexus] state subscriber failed", error); + } + }); + } + + function writeScope(name, value) { + if (!signals[name]) { + signals[name] = signal(value); + renderAll(); + notifyState(name, value, undefined); + return; + } + var previous = signals[name].get(); + if (Object.is(previous, value)) return; + signals[name].set(value); + notifyState(name, value, previous); + } + + function evalExpr(expr, locals) { + return evaluateExpression(expr, function (name) { + if (locals && Object.prototype.hasOwnProperty.call(locals, name)) { + return locals[name]; + } + return readScope(name); + }); + } + + function runStmt(stmt, locals) { + splitStatements(stmt).forEach(function (part) { + runStatement( + part, + function (expression) { return evalExpr(expression, locals); }, + function (name) { + if (locals && Object.prototype.hasOwnProperty.call(locals, name)) return locals[name]; + return peekScope(name); + }, + function (name, value) { + if (locals && Object.prototype.hasOwnProperty.call(locals, name)) { + locals[name] = value; + } else { + writeScope(name, value); + } + }, + ); + }); + } + + function installBehaviorFunctions(source) { + extractBehaviorFunctions(source || "").forEach(function (definition) { + behaviorFunctions[definition.name] = function () { + var locals = {}; + for (var i = 0; i < definition.args.length; i++) { + locals[definition.args[i]] = arguments[i]; + } + runStmt(definition.body, locals); + }; }); } @@ -287,27 +477,146 @@ export const REACTIVE_RUNTIME = String.raw` })(textNode, template); } - // data-on- handlers, on the scope element and the descendants it owns. + // Event handlers on elements, window, and document. var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*"))); nodes.forEach(function (node) { if (!owns(node)) return; Array.prototype.slice.call(node.attributes).forEach(function (attr) { if (attr.name.indexOf("data-on-") !== 0) return; - var evt = attr.name.slice("data-on-".length); + + var rawName = attr.name.slice("data-on-".length); + var target = node; + var evt = rawName; + + if (rawName.indexOf("window-") === 0) { + target = window; + evt = rawName.slice("window-".length); + } else if (rawName.indexOf("document-") === 0) { + target = document; + evt = rawName.slice("document-".length); + } + var stmt = attr.value; - node.addEventListener(evt, function () { - try { runStmt(stmt); } catch (e) { - console.error("[wrnexus] handler error in '" + stmt + "'", e); + var listener = function (event) { + try { runStmt(stmt, { event: event, $event: event }); } catch (error) { + console.error("[wrnexus] handler error in '" + stmt + "'", error); } + }; + + var options = + target === window && (evt === "scroll" || evt === "touchstart" || evt === "touchmove") + ? { passive: true } + : undefined; + + target.addEventListener(evt, listener, options); + cleanupCallbacks.push(function () { + target.removeEventListener(evt, listener, options); }); }); }); + var behavior = parseBehavior(el); + if (behavior) { + installBehaviorFunctions(behavior.functions); + + (behavior.watches || []).forEach(function (watch) { + if (!watch || typeof watch.state !== "string" || typeof watch.body !== "string") return; + if (!stateWatchers[watch.state]) stateWatchers[watch.state] = []; + stateWatchers[watch.state].push(function (value, previous) { + runStmt(watch.body, { value: value, previous: previous }); + }); + }); + + var updateSource = + behavior.lifecycle && typeof behavior.lifecycle.update === "string" + ? behavior.lifecycle.update.trim() + : ""; + + if (updateSource) { + anyStateListeners.add(function () { + scheduleUpdateHook(el, function () { + if (!disposed) runStmt(updateSource); + }); + }); + } + + var mountSource = + behavior.lifecycle && typeof behavior.lifecycle.mount === "string" + ? behavior.lifecycle.mount.trim() + : ""; + + var unmountSource = + behavior.lifecycle && typeof behavior.lifecycle.unmount === "string" + ? behavior.lifecycle.unmount.trim() + : ""; + + var instance = { + dispose: function () { + if (disposed) return; + disposed = true; + cleanupCallbacks.splice(0).reverse().forEach(function (cleanup) { + try { cleanup(); } catch (_) { /* ignore cleanup errors */ } + }); + pendingUpdateHooks.delete(el); + if (unmountSource) { + try { runStmt(unmountSource); } catch (error) { + console.error("[wrnexus] component unmount hook failed", error); + } + } + mountedBehaviorRoots.delete(el); + behaviorInstances.delete(el); + el.__wrnexusScope = false; + }, + }; + + behaviorInstances.set(el, instance); + mountedBehaviorRoots.add(el); + + if (mountSource) { + queueMicrotask(function () { + if (!disposed && el.isConnected) { + try { runStmt(mountSource); } catch (error) { + console.error("[wrnexus] component mount hook failed", error); + } + } + }); + } + } + renderAll(); } + function disposeBehaviors(root) { + var elements = []; + if (root && root.nodeType === 1 && root.hasAttribute("data-wrn-behavior")) { + elements.push(root); + } + if (root && root.querySelectorAll) { + Array.prototype.push.apply(elements, root.querySelectorAll("[data-wrn-behavior]")); + } + elements.reverse().forEach(function (element) { + var instance = behaviorInstances.get(element); + if (instance) instance.dispose(); + }); + } + + function ensureBehaviorObserver() { + if (behaviorObserver || typeof MutationObserver === "undefined") return; + behaviorObserver = new MutationObserver(function (records) { + records.forEach(function (record) { + Array.prototype.forEach.call(record.removedNodes, function (node) { + if (node && node.nodeType === 1) disposeBehaviors(node); + }); + }); + }); + behaviorObserver.observe(document.documentElement, { childList: true, subtree: true }); + } + function hydrateScopes(root) { - (root || document).querySelectorAll("[data-scope]").forEach(setupScope); + var host = root || document; + if (host.nodeType === 1 && host.matches && host.matches("[data-scope]")) setupScope(host); + if (host.querySelectorAll) host.querySelectorAll("[data-scope]").forEach(setupScope); + ensureBehaviorObserver(); } function parseScopeDecl(decl) { @@ -635,6 +944,7 @@ export const REACTIVE_RUNTIME = String.raw` window.__wrnexusHydrateScopes = hydrateScopes; window.__wrnexusHydrateCsrFetches = hydrateCsrFetches; + window.__wrnexusDisposeBehaviors = disposeBehaviors; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { hydrateScopes(document); diff --git a/packages/db/package.json b/packages/db/package.json index 7f56db4d..5f7945ef 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 2567c2dd..3079b3c3 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 1a49e1b4..277e1ca2 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 81581c21..1be148c7 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index e67e265f..96e2d25a 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index 14c14e12..c9976710 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 070b1393..5987d781 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index 9e435999..e94dc4e2 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index fd5b398c..5246c971 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 8507e3d9..2666ed0e 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index 10b9f720..e1f30a7e 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index 60f691e5..80fbc1fb 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/package.json b/packages/router/package.json index 667d1ef1..cd0c372b 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index 771701ff..84293f68 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index 24edc9ed..b7b8059d 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.30", + "version": "0.2.31", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/test/package.json b/packages/test/package.json index f09f56c6..08b19609 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 652c4c22..4ec18f8b 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index 259cf88e..30706e92 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/uploader/package.json b/packages/uploader/package.json index 2bd8204e..7ed7f3e4 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/package.json b/packages/validation/package.json index eda60bb4..b713242c 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.30", + "version": "0.2.31", "private": true, "type": "module", "main": "src/index.ts",