Skip to content

feat: use Node.js timers by default and remove global proxy#33249

Merged
bartlomieju merged 4 commits into
mainfrom
reland_33118_33122
Apr 29, 2026
Merged

feat: use Node.js timers by default and remove global proxy#33249
bartlomieju merged 4 commits into
mainfrom
reland_33118_33122

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

Summary

Reland of #33118 and #33122 (reverted in #33211).

  • Switches global setTimeout/setInterval/clearTimeout/clearInterval to use Node.js timer APIs instead of Web APIs by default
  • Timer IDs are now Timeout objects instead of numbers (Node.js Timeout has [Symbol.toPrimitive] so numeric coercion still works)
  • Removes the v8 named property handler proxy in ext/node/global.rs that intercepted global accesses based on Node/Deno context
  • Simplifies the CJS module wrapper to match Node.js -- no longer destructures globals from nodeGlobals bag

🤖 Generated with Claude Code

Reland of #33118 and #33122 (reverted in #33211).

- Switches global setTimeout/setInterval/clearTimeout/clearInterval to
  use Node.js timer APIs instead of Web APIs by default
- Timer IDs are now Timeout objects instead of numbers
- Removes the v8 named property handler proxy in ext/node/global.rs
- Simplifies CJS wrapper to match Node.js

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@bartlomieju bartlomieju modified the milestones: 3.0, 2.8.0 Apr 12, 2026
@bartlomieju

Copy link
Copy Markdown
Member Author

Fixing the type declarations for timer return types

With this PR changing setTimeout/setInterval to return Timeout objects instead of numbers, the type declarations need to match. Currently lib.deno.shared_globals.d.ts declares setTimeout returning number, which will be wrong after this lands.

Root cause: node types not always loaded

Deno provides node globals (Buffer, process, setTimeout, etc.) at runtime unconditionally, but their types are only included when node: imports are detected in the module graph (see GraphWalker.has_seen_node_builtin in cli/type_checker.rs). This is tracked in #30138.

Proposed approach

  1. Always inject node types — change type_checker.rs to unconditionally inject reference_types_node.d.ts (remove the has_seen_node_builtin guard). Deno already provides all node globals at runtime, so the types should always be available. The existing TYPES_NODE_IGNORABLE_NAMES mechanism already prevents conflicts for types where Deno's declaration should take precedence (like fetch, URL, Response, etc.).

  2. Remove timer declarations from Deno's lib files — delete setTimeout, setInterval, clearTimeout, clearInterval from lib.deno.shared_globals.d.ts. Let @types/node's timers.d.cts global declarations provide them instead (they return NodeJS.Timeout which matches the new runtime behavior).

  3. Remove timers from NODE_ONLY_GLOBALSsetTimeout/setInterval/clearTimeout/clearInterval are currently in NODE_ONLY_GLOBALS which filters them out in non-node contexts. Since we're always loading node types and these are genuine Deno globals, they should not be filtered.

This way:

  • setTimeout returns NodeJS.Timeout in types, matching the new runtime behavior
  • NodeJS.Timeout has [Symbol.toPrimitive](): number so numeric coercion still type-checks
  • clearTimeout accepts NodeJS.Timeout | number | undefined which is backward compatible
  • Buffer and other node globals (Node globals missing in types #30138) also get proper types without needing explicit @types/node
  • No need to forward-declare or reference NodeJS.Timeout from Deno's lib files — just don't declare the timers there at all

@lunadogbot lunadogbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the timer plumbing in 02_timers.js, the global-scope swap in 98_global_scope_shared.js, the test ignore list in tests/unit/timers_test.ts, and the WPT expectation diffs. The wiring is correct -- setTimeout/setInterval/clearTimeout/clearInterval now point at nodeSet*/nodeClear* from the windowOrWorkerGlobalScope record, the unstable nodeGlobals branch is gone, and the Timeout-vs-number impedance is handled in refTimer/unrefTimer via the new if (typeof id !== 'number') id = id[kTimerId] cast.

Three concerns I'd want explicit decisions on before merging -- not blocking, but each is a user-visible spec/contract change that may surprise downstream code:

1. WHATWG HTML spec regressions are now permanent. tests/unit/timers_test.ts marks 5 cases as Deno.test.ignore with // TODO(bartlomieju): this test is not valid -- these cover string-callback eval (setTimeout("code", 100)), this-binding to globalThis, and primordial scope isolation, all of which the HTML spec mandates. The PR body doesn't note these as casualties; worth at least a line in the PR description and a release note so users don't think string-callback support disappeared as a bug.

2. refTimer(id)/unrefTimer(id) null-safety changed. Old getActiveTimer(null) returned undefined and the if (timer) guard skipped silently. New code does if (typeof id !== 'number') id = id[kTimerId] before the guard, so passing null or undefined now throws TypeError: Cannot read properties of null/undefined (reading 'Symbol(...)' ). Code in the wild that defensively calls unrefTimer(timer.id) where timer may have already been cleared would change from no-op to throw. A if (id == null) return; early-return at the top of both functions would preserve the prior behavior cheaply.

3. setTimeout return type is now Timeout not number. The body says numeric coercion still works via [Symbol.toPrimitive], which is true, but reference-equality patterns silently break: const ids = new Set(); ids.add(setTimeout(fn,1)); ids.has(1) now returns false because the Set stored a Timeout object. Number(id) === id also flips from true to false. Probably acceptable as a Node-compat trade-off, but again worth a release-note callout for users who used to do Map<number, ...> keyed by timer IDs.

Didn't review ext/node/global.rs removal in detail (487 lines deleted, replacing the v8 named-property handler proxy) -- trusting that the test suite passing covers the cases that proxy was load-bearing for. If there are subtle global-access patterns it was intercepting, those would only surface in user code, not CI.

@bartlomieju
bartlomieju enabled auto-merge (squash) April 28, 2026 17:40
# Conflicts:
#	ext/node/polyfills/02_init.js
The symbol was removed from stream_wrap.ts in #33297 but the import
in 02_init.js survived the merge, breaking the snapshot build.
@bartlomieju
bartlomieju merged commit 543bec9 into main Apr 29, 2026
267 of 270 checks passed
@bartlomieju
bartlomieju deleted the reland_33118_33122 branch April 29, 2026 09:01
bartlomieju added a commit that referenced this pull request Jun 17, 2026
Node.js `setTimeout`, `setInterval`, `clearTimeout`, and `clearInterval`
globals became the default in #33249, so the `node-globals` unstable
feature now only re-installs the exact same functions that are already
present on the global scope. Enabling it does nothing, which makes the
`--unstable-node-globals` flag a no-op.

This hides the flag from `--help`, removes the dead runtime block that
re-installed the node timers, and stops the Node-compat argument
translator from emitting the redundant flag. The feature entry itself is
kept so existing callers passing `--unstable-node-globals` (or running
with `DENO_COMPAT=1`) continue to parse without erroring.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants