fix(js): run string timer handlers as global-scope scripts at fire time

A string setTimeout/setInterval handler was compiled with new Function(fn) at
scheduling time. That diverged from the HTML spec in two ways: top-level
var/function declarations in the body stayed local to the compiled function
instead of becoming globals, and a syntax error was caught eagerly at
scheduling and turned into a silent no-op (that still returned a timer id)
rather than surfacing when the timer fires.

Defer string handlers to fire-time indirect eval ((0, eval)(src)), which runs
in the true global scope like a classic script, so declarations become global
and a SyntaxError is thrown when the timer elapses. Matches the dynamic-script
path, which already uses indirect eval for the same reason. Function handlers
are unchanged.

Fixes #558
This commit is contained in:
Andrey Mnatsakanov
2026-08-04 17:40:19 +02:00
parent f0541f40a4
commit 452cc8554f
2 changed files with 29 additions and 1 deletions

View File

@@ -481,7 +481,15 @@ const _scheduleAfter = (delay, fn) => {
// call silently no-ops and JS-triggered navigations (cookie → reload) never fire.
const _coerceTimerFn = (fn) => {
if (typeof fn === "string") {
try { return new Function(fn); } catch (_) { return null; }
// Per HTML, a string handler is compiled and run as a classic script in
// global scope *at fire time*. Indirect eval ((0, eval)) runs in the true
// global scope, so top-level var/function declarations become globals (a
// `new Function(fn)` wrapper kept them local); deferring to fire time also
// surfaces a SyntaxError when the timer elapses, matching a real browser,
// instead of swallowing it eagerly at scheduling. The dynamic-script path
// uses the same indirect eval for the same reason.
const src = fn;
return () => { (0, eval)(src); };
}
return typeof fn === "function" ? fn : null;
};

View File

@@ -1330,6 +1330,26 @@ mod tests {
);
}
#[tokio::test(flavor = "current_thread")]
async fn string_timeout_declarations_reach_global_scope() {
// A string timer handler runs as a classic script in global scope, so a
// top-level var/function declaration in it becomes a global. new Function()
// kept those declarations local to the compiled function, so they never
// reached globalThis.
let mut rt = setup_runtime("<html><body></body></html>");
rt.evaluate(
"setTimeout('var __leaked = 42; function __leakedFn(){ return 7; }', 0)",
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
let v = rt
.evaluate(
"String(globalThis.__leaked) + '|' + (typeof globalThis.__leakedFn === 'function' ? globalThis.__leakedFn() : 'missing')",
)
.unwrap();
assert_eq!(v, serde_json::json!("42|7"));
}
#[tokio::test(flavor = "current_thread")]
async fn string_interval_handler_repeats_and_can_clear_itself() {
let mut rt = setup_runtime("<html><body></body></html>");