Doors is a Go UI runtime that keeps page state on the server and treats the browser as a remote renderer. This week doors-dev/doors landed two client changes on main across 11 files, 217 insertions, and 7 deletions. $sys.emit waits for Go hook requests after a DOM dispatch, and history.pushState no longer copies foreign history state into new entries.
$sys.emit waits for the same hooks Go already owns ¶
Synthetic events from Go already went through doors.Emitter. Inline scripts could fire a native dispatchEvent, but they had no way to wait for the hook requests that event attrs attach to those elements. The commit that adds $sys.emit puts $sys.emit(target, event) on the client $sys object.
The contract matches doors.Emitter. Dispatch the event, then resolve only after every hook request that event started has finished, including HoldSettle and After actions. The resolved value is a count of hook requests, not a count of elements. An element with no matching event attr adds nothing. A bubbling event adds one per event attr it reaches.
The JavaScript docs now list the signature next to $sys.ready, $sys.clean, and $sys.activateLinks. TypeScript inline scripts pick up the same type from internal/resources/providers.go.
That wait is what an admin tool needs when a Go handler queries a store. A chart widget or a file picker in the browser can fire the same AClick or ASubmit path a real pointer event would, then wait until the handler that hits the database has settled. You do not invent a second hook name just to sequence the next step.
Shared dispatch, not a second event path ¶
The implementation is small. internal/client/trigger.ts already stored trigger metadata on the Event object with putTrigger. The emit_event action in internal/client/calls.ts used to call putTrigger, dispatchEvent, then collect meta.promises. Both call sites now go through a dispatch(target, event) helper that does those three steps once.
$sys.emit in internal/client/index.ts waits for controller.ready, calls dispatch, awaits Promise.all on the collected promises, and returns promises.length. Failed hook requests reject with HookErr. A hook canceled by a scope or filtered out by Keys is a failure too. Top level script is safe because the helper waits for readiness before it dispatches.
The server path still builds the Event from a type string and a capture kind, then hands it to the same helper. The client path lets you pass any Event you constructed. After that, the count semantics match. Error wrapping does not. emit_event turns a HookErr into a plain Error with kind: message before it returns to Go. $sys.emit lets HookErr reject the promise directly, so scripts should catch HookErr, not a string.
There is no new wire protocol. The client still talks over the existing rolling HTTP request path. Sequencing is the only new behavior.
The test waits 200ms on two bubbling handlers ¶
The fixture in internal/test/attr/sys_emit.gox is the spec. Nested div#outer and div#inner each carry AClick. Each click handler sleeps 200ms, increments an atomic, and reports the pointer button. A script then runs:
const n = await $sys.emit(
document.getElementById("inner"),
new PointerEvent("click", { bubbles: true, button: 2 }),
)
await $hook("count", n)
The test asserts four report slots all equal "2". Both handlers saw button 2, the hook received request count 2, and both 200ms handlers had finished before $sys.emit resolved. That last check is the whole point. If the promise resolved at dispatch time, slot 3 would still be 0.
You build the Event yourself. PointerEvent, KeyboardEvent, InputEvent, and CustomEvent all work. Set bubbles: true when ancestor attrs should run. That matches doors.Emitter, which also bubbles.
Pushed history entries no longer inherit foreign state ¶
The second commit is one line in internal/client/navigator.ts. Navigator.push used to spread the previous history.state into the new entry and then overwrite _d0r. The fix that stops the copy now copies only _d0ri:
history.pushState({ _d0ri: history.state?._d0ri, _d0r: { id } }, '', path);
Pushed entries inherited the full previous state object. Only _d0ri must carry over. Replay style back navigation with no bfcache runs the original document again at the pushed entry, and reload recovery depends on finding the instance id there.
_d0ri is written in repeatedLoad() in the client controller. If history.state._d0ri already equals the current page instance id, the load is treated as a repeat and the controller reloads instead of initializing a second live session. If a push copied some other page’s fields into the new entry, that check could fire on the wrong document, or a later pop could restore state that never belonged to that URL.
The replaceState just before the push still spreads _d0r.next onto the current entry. That is intentional. The current entry needs the next id so a later pop can pair with the push. The new entry must not inherit the rest.
Reload recovery and back navigation stay tied to the instance that actually rendered the document. Any code that stuffed extra keys into history.state and expected pushState to copy them now loses that copy. Doors never documented that copy, and the old spread was a footgun against the browser History API, which already lets other scripts write history.state.
What to watch ¶
If inline scripts already call element.dispatchEvent(...) and then $hook(...) to sequence work, switch them to $sys.emit. The helper waits for HoldSettle and After actions, which a raw dispatch does not.
Treat history.state as owned by Doors except for _d0ri on pushed entries. Do not spread your own keys onto history.state and expect them to survive a Doors navigation.
There is no release tag in this window. The work sits on main after 0.15. Pin a commit if you ship from this tree before the next tag.