diff --git a/packages/compiler/README.md b/packages/compiler/README.md index 4fec3363..7eeeea82 100644 --- a/packages/compiler/README.md +++ b/packages/compiler/README.md @@ -201,6 +201,7 @@ A file opens with `page ` or `component ` followed by a `{ ... }` bo - `@event name = function` inside `props` — declares a public component event. Emit it from component behavior with `name(detail)` or `$emit("name", detail)`, and consume it with ``. - `state : Type = ` — typed reactive state seeded from a raw JS expression, including native array and object literals. The annotation is optional for backward compatibility. - `view { }` — plain HTML with `{expr}` interpolation in text and attributes, JSX-style component props such as `items={items}`, `items={[...]}`, and `options={{...}}`, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and ``. Structured component props are serialized safely for SSR; expressions that reference `state` retain their initial value and update reactively in the browser. +- Client functions automatically commit state changed by `setTimeout` callbacks. For other deferred callbacks (observers, third-party APIs, or detached promise callbacks), call the injected `commit()` function after changing local state; returning/awaiting a promise also commits through the normal function boundary. - `seo { key = "value" ... }` — metadata merged into the generated `meta`. - `style { }` — inlined page/component stylesheet (repeatable). - `functions { }` — helpers with typed parameters and return values. Types remain in server output and are safely erased from browser behavior code. diff --git a/packages/compiler/src/client-codegen.ts b/packages/compiler/src/client-codegen.ts index 9e7660ca..c015b529 100644 --- a/packages/compiler/src/client-codegen.ts +++ b/packages/compiler/src/client-codegen.ts @@ -268,6 +268,13 @@ function functionEntry( `if (!Object.is(${name}, __wrnexusInitialState[${JSON.stringify(name)}])) context.state.${name} = ${name};`, ) .join("\n"); + const commitBinding = stateNames.length + ? `const __wrnexusCommit = () => { ${copyBack} }; + ${!parameterNames.has("commit") && !declaredLocals.has("commit") ? "const commit = __wrnexusCommit;" : ""} + ${!parameterNames.has("setTimeout") && !declaredLocals.has("setTimeout") ? `const setTimeout = (callback, delay, ...args) => globalThis.setTimeout(() => { + try { return callback(...args); } finally { __wrnexusCommit(); } + }, delay);` : ""}` + : ""; const body = eraseFunctionTypes(fn.body); const runtimeBindings = [ !parameterNames.has("output") ? "const output = context.output;" : "", @@ -281,12 +288,13 @@ function functionEntry( ${initialStateSnapshot} ${stateAliases} ${propAliases} + ${commitBinding} ${peerAliases} ${runtimeBindings} try { ${body} } finally { - ${copyBack} + ${stateNames.length ? "__wrnexusCommit();" : ""} } }`; } diff --git a/packages/compiler/test/v060-targets.test.ts b/packages/compiler/test/v060-targets.test.ts index 7dc32b27..37b2530e 100644 --- a/packages/compiler/test/v060-targets.test.ts +++ b/packages/compiler/test/v060-targets.test.ts @@ -145,3 +145,42 @@ test("browser codegen binds peer client functions through the scoped function ta functions.run?.(); expect(state.value).toBe(1); }); + +test("deferred timer state writes commit after the client function returns", async () => { + const targets = generateTargets( + parse(`component DeferredWrite { + state { value: number = 0 } + functions { + client function later(): void { setTimeout(() => { value = 2 }, 0) } + } + view { } + }`), + ); + const executable = new Function( + `${targets.browser.replace(/^export\s+/gm, "")}\nreturn { bindClientScope };`, + )() as { bindClientScope: (context: Record) => Record void> }; + let rendered = "0"; + const state = new Proxy( + { value: 0 }, + { + set(target, property, value) { + Reflect.set(target, property, value); + rendered = String(value); + return true; + }, + }, + ); + const functions = executable.bindClientScope({ + state, + props: {}, + output: {}, + server: {}, + refs: {}, + }); + + functions.later?.(); + expect(state.value).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(state.value).toBe(2); + expect(rendered).toBe("2"); +});