How the first interaction wakes the app
In every environment, one delegated listener per event name waits on the container, and the first matching event loads the code for that event and nothing else.
Last page, the state and view records traveled inside server HTML. In the browser alone, the same records stay in memory. Either way, somebody now clicks the button.
At that moment, no event code has loaded. So who hears the click?
The problem
The page looks ready, but a click needs code. That code has not loaded yet. Something must listen before any event code exists. It must also find the right code for the exact element the user clicked.
Markless calls this step resuming. To resume means to continue from the page as it is, without building it again. Hydration is the common alternative for server-rendered pages. It runs the component code again in the browser to attach listeners. Markless never does that.
One mechanism, every environment
The wake-up works the same way wherever the page came from. It has four parts:
- At compile time, the compiler writes an event record for each handler. The record names the element, the event, and the symbol to run.
- At mount, Markless adds one capture-phase listener per event name to the page’s container. It loads no event code.
- On the first matching event, the listener walks up from the target to the container. It looks up the element plus the event name. If a record matches, Markless loads that record’s symbol.
- The symbol runs. It writes state, and the state graph updates the text that reads it.
A symbol is one compiled function with a stable ID, such as symbol:0. Event delegation means one listener on an ancestor element handles events from all its descendants.
This is an event record. Its type is ProtocolEventRecord in @markless/serializer:
{ "hostNodeId": "h1", "eventName": "click", "symbolIds": ["symbol:0"] }
Think of a night porter with a guest list. That is an analogy. The porter does nothing until a guest rings. Then the porter reads the list, finds the room, and calls one person. The porter is the container listener. The guest list is the event records.
Pick how the page was built. Then press Next, or drag the timeline, until the number changes.
What happens on the first click?
Drag the timeline, or click Count in the page to jump to the next click. Then switch how the page was built and watch which steps stay the same.
No server. Your bundle calls render(). The component body runs one time and builds the page, already showing Count 0.
import { render } from '@markless/core';import App from './App.tsrx'; await render(App, { target: document.getElementById('app') }); (body runs once)- loadedYour app bundle, which calls render()
Simplified. Record shapes and load order follow packages/web/test/render.test.ts and packages/web/src/inline/resumer.ts. Production builds can merge some of these loads. Build-time HTML is a preview feature. Tests run the same two paths through @markless/vitest-browser.
Notice that the component itself never runs on the click. Only the handler and one text update run.
In the browser alone
You can mount with render() and no server at all:
import { render } from '@markless/core';
import App from './App.tsrx';
await render(App, { target: document.querySelector('#app')! });
The component body runs once in the browser, to build the DOM. Then the four parts play out like this:
render()adds one capture-phase listener per event name to the container.- At mount, no handler symbol has loaded. The state graph and the full runtime have not loaded either.
- The first click loads that click’s symbol. It also loads the graph and the runtime, because nothing needed them before.
Markless’s own test for this mounts a counter, then clicks once. The component body ran once, no symbol loaded at mount, and the click loaded only symbol:click. The test also checks that there is no inline script and no payload script.
On a server-rendered page
Here the server ran the component body, and the browser never runs it. The records travel as two JSON scripts inside the container, next to one small inline script. That inline script plays the listener’s part:
- It reads the
markless/viewscript and walks the container’s elements once, in document order. - It maps each locator to an element. A locator is a record that names an element by its position in that walk.
- It adds one capture-phase listener per event name to the container. It imports no app code.
When a record matches, the inline script imports the page’s resume module and passes the event to it. The bundler generates the resume module from your component file. It queues events, so they run in the order they fired. Then it picks a path:
- If the compiler specialized every event and text update on the page, the module runs that specialized code. It does not load the full runtime.
- Otherwise it loads the full runtime and builds the state graph from the
markless/statescript.
A server page with nothing to react to gets no inline script at all.
The two environments side by side
| Question | Browser-only render() |
Server-rendered page |
|---|---|---|
| Who builds the first DOM? | The component body, once, in the browser | The server |
| Who owns the container listeners? | render() |
The inline script |
| What listens? | One container listener per event name | One container listener per event name |
| When does a handler first run? | On its first matching event | On its first matching event |
| Inline script in the HTML? | No | Yes, if the page can react |
Next: The handler wrote count. How did Markless know which text reads it? The state graph →
