Skip to content

Commit 32bd92a

Browse files
committed
fix: Drop events raised before the vtree is mounted (hydration race)
## Problem On server-rendered pages (e.g. haskell-miso.org) the browser console fills with repeated errors while the wasm app is hydrating: Uncaught TypeError: Cannot read properties of null (reading 'type') at delegateEvent (ghc_wasm_jsffi.js:1038:13) at dispatch (ghc_wasm_jsffi.js:1016:5) `delegateEvent` reads `obj.type`, where `obj` is the vtree -- and the vtree is `null`. ## The race `Miso.Runtime.initialize` does, in program order: 1. `_componentVTree <- newIORef (VTree (Object jsNull))` -- the vtree ref starts out null. 2. `delegator _componentDOMRef _componentVTree events ...` -- DOM listeners for every delegated event attach to the mount point, synchronously, via the FFI. 3. `initialDraw` -- only now is the vtree built and `Hydrate.hydrate` run, and only after hydration succeeds is the ref written. Steps 2 and 3 are not one atomic JS task. The wasm backend runs Haskell as promise continuations on the browser event loop, yielding at every JSFFI round-trip and scheduler timeslice. `initialDraw` builds the whole vtree and walks the whole server-rendered DOM, so the window between "listeners live" and "vtree written" spans many event-loop turns. Meanwhile SSR means the page is painted and interactive-looking before the wasm even finishes loading -- the user is already mousing and clicking. Every event delivered inside that window runs: listener -> getVTree -> readIORef (still jsNull) -> delegateEvent(event, null, stack, ...) -> null.type Mouse events fire in bursts as the cursor crosses elements, hence the flood of identical errors. The invariant the delegator assumed -- "if my listeners are installed, the vtree exists" -- only holds in the non-SSR path, where the first draw paints the DOM before the user can interact. Hydration breaks it because the DOM (and the user's input stream) exists before miso does. ## Fix Short-circuit upstream in `listener` (ts/miso/event.ts): if the vtree has not been stored yet, drop the event, with a warning in debug mode. Dropping is semantically correct -- pre-hydration there are no handlers in the vtree, so those events had no observable behavior to lose. The Lynx BTS delegator (ts/miso/native/bts/context.ts) bypasses `listener` and calls `delegateEvent` directly against the same jsNull-initialized ref, so it gets the identical guard. Installing the delegator before hydration is kept intentionally: installing it after would drop the same events anyway (silently, at the browser level, since no listener exists yet), and early installation leaves the door open to queueing and replaying pre-hydration events later. ## Testing - New regression test in ts/spec/event.spec.ts installs the delegator with `getVTree` yielding null and clicks server-rendered markup. Listener exceptions do not propagate to the dispatching `.click()` -- they surface as `error` events on `window` -- so the test captures those and asserts none fire. Verified the test fails with exactly the reported TypeError when the guard is removed. - `bun test ts/spec/event.spec.ts ts/spec/native-bts.spec.ts`: 43 pass, 0 fail. - `bun run js`: all four shipped bundles (js/miso.js, js/miso.prod.js, js/miso-native.js, js/miso-native.prod.js) regenerated and each now contains the guard.
1 parent 877496a commit 32bd92a

7 files changed

Lines changed: 52 additions & 3 deletions

File tree

js/miso-native.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/miso-native.prod.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/miso.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,12 @@ function delegator(mount, events, getVTree, debug, context) {
865865
}
866866
function listener(e, mount, getVTree, debug, context) {
867867
getVTree(function(vtree) {
868+
if (!vtree) {
869+
if (debug) {
870+
console.warn("Event received before vtree was mounted, dropping", e);
871+
}
872+
return;
873+
}
868874
if (Array.isArray(e)) {
869875
for (const key of e) {
870876
dispatch(key, vtree, mount, debug, context);

js/miso.prod.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ts/miso/event.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ export function delegator<T> (
2828
/* the event listener shared by both delegator and undelegator */
2929
function listener<T>(e: Event | [Event], mount: T, getVTree: ((callback: (vtree: VTree<T>) => void) => void), debug: boolean, context: EventContext<T>): void {
3030
getVTree(function (vtree: VTree<T>) {
31+
/* The delegator's listeners attach before the initial draw / hydration
32+
has stored the vtree, so events raised in that window see a null
33+
vtree. Drop them: there is nothing to dispatch on yet. */
34+
if (!vtree) {
35+
if (debug) {
36+
console.warn('Event received before vtree was mounted, dropping', e);
37+
}
38+
return;
39+
}
3140
if (Array.isArray(e)) {
3241
for (const key of e) {
3342
dispatch (key, vtree, mount, debug, context);

ts/miso/native/bts/context.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ const eventContext : EventContext<NodeId> = {
4545
context.addEventListener('Miso.events', (m : MessageEvent<ProcessEvent>) => {
4646
let stack : Array<NodeId> = m.data.stack.map (function (x) { return { nodeId : x }});
4747
getVTree((vtree: VTree<NodeId>) => {
48+
/* The delegator's listeners attach before the initial draw has stored
49+
the vtree, so events raised in that window see a null vtree. Drop
50+
them: there is nothing to dispatch on yet. */
51+
if (!vtree) {
52+
if (debug) {
53+
console.warn('Event received before vtree was mounted, dropping', m.data.event);
54+
}
55+
return;
56+
}
4857
return delegateEvent(m.data.event as Event, vtree, stack, debug, eventContext);
4958
});
5059
});

ts/spec/event.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,31 @@ describe ('Event tests', () => {
7373

7474
});
7575

76+
test('Should drop events raised before the vtree is mounted (hydration race)', () => {
77+
const body = document.body;
78+
/* server-rendered markup exists before hydration has produced a vtree */
79+
body.innerHTML = '<div><button></button></div>';
80+
81+
/* exceptions in event listeners don't propagate to the dispatching
82+
click(), they surface as 'error' events on window, so capture those */
83+
const errors: Array<string> = [];
84+
const onError = (e: any) => { errors.push(e.message); };
85+
window.addEventListener('error', onError);
86+
87+
/* the delegator is installed before initialDraw / hydration has written
88+
the vtree ref, so getVTree yields null for events in that window */
89+
const getVTree = (cb: any) => cb(null);
90+
const delegatedEvents: Array<EventCapture> = [{ name: 'click', capture: true }];
91+
delegator(body, delegatedEvents, getVTree, true, eventContext);
92+
93+
(body.querySelector('button') as HTMLElement).click();
94+
95+
window.removeEventListener('error', onError);
96+
/* without the null-vtree guard this captures:
97+
"Cannot read properties of null (reading 'type')" */
98+
expect(errors).toEqual([]);
99+
});
100+
76101
test('Should warn when clicking mount with no target handler (empty stack)', () => {
77102
const body = document.body;
78103
const parent = vnode({ tag: 'div', children: [vnode({ tag: 'span' })], events: { captures: {}, bubbles: {} } });

0 commit comments

Comments
 (0)