Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions doc/api/assert.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,9 @@ An alias of [`assert.ok()`][].
<!-- YAML
added: v0.1.21
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/67059
description: Temporal objects are compared by value.
- version: v26.0.0
pr-url: https://github.com/nodejs/node/pull/58849
description: Message may now be a `printf`-like format string or function.
Expand Down Expand Up @@ -444,6 +447,9 @@ are also recursively evaluated by the following rules.
will result in inequality, even if they contain the same content.
* {RegExp} lastIndex, flags, and source are always compared, even if these
are not enumerable properties.
* Temporal objects are compared by value: `Temporal.Duration` objects field by
field, so `PT1H` and `PT60M` are not equal, and all other Temporal objects
with their `equals()` method.

The following example does not throw an [`AssertionError`][] because the
primitives are compared using the [`==` operator][].
Expand Down Expand Up @@ -546,6 +552,9 @@ parameter is an instance of {Error} then it will be thrown instead of the
<!-- YAML
added: v1.2.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/67059
description: Temporal objects are compared by value.
- version: v25.1.0
pr-url: https://github.com/nodejs/node/pull/58849
description: Message may now be a `printf`-like format string or function.
Expand Down Expand Up @@ -628,6 +637,9 @@ are recursively evaluated also by the following rules.
will result in inequality, even if they contain the same content.
* {RegExp} lastIndex, flags, and source are always compared, even if these
are not enumerable properties.
* Temporal objects are compared by value: `Temporal.Duration` objects field by
field, so `PT1H` and `PT60M` are not equal, and all other Temporal objects
with their `equals()` method.

```mjs
import assert from 'node:assert/strict';
Expand Down Expand Up @@ -2368,6 +2380,9 @@ added:
- v23.4.0
- v22.13.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/67059
description: Temporal objects are compared by value.
- version: v25.0.0
pr-url: https://github.com/nodejs/node/pull/59448
description: Promises are not considered equal anymore if they are not of
Expand Down Expand Up @@ -2416,6 +2431,9 @@ behaving as a super set of it.
will result in inequality, even if they contain the same content.
* {RegExp} lastIndex, flags, and source are always compared, even if these
are not enumerable properties.
* Temporal objects are compared by value: `Temporal.Duration` objects field by
field, so `PT1H` and `PT60M` are not equal, and all other Temporal objects
with their `equals()` method.
* Holes in sparse arrays are ignored.

```mjs
Expand Down
3 changes: 3 additions & 0 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -1854,6 +1854,9 @@ console.log(arr); // logs the full array
<!-- YAML
added: v9.0.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/67059
description: Temporal objects are compared by value.
- version: v24.9.0
pr-url: https://github.com/nodejs/node/pull/59762
description: Added `options` parameter to allow skipping prototype comparison.
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/process/pre_execution.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ function prepareExecution(options) {
require('internal/dns/utils').initializeDns();
}

require('internal/util/temporal').initialize();

if (isMainThread) {
assert(internalBinding('worker').isMainThread);
// Worker threads will get the manifest in the message handler.
Expand Down
73 changes: 72 additions & 1 deletion lib/internal/util/comparisons.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const {
SafeSet,
Set,
String,
StringPrototypeSlice,
StringPrototypeStartsWith,
StringPrototypeValueOf,
Symbol,
SymbolPrototypeValueOf,
Expand Down Expand Up @@ -131,6 +133,7 @@ let getCryptoKeyAlgorithm;
let getCryptoKeyUsagesMask;
let getCryptoKeySecondaryHandle;
let getCryptoKeySeedData;
let temporalComparators;
let getKeyObjectHandle;
let getKeyObjectType;

Expand Down Expand Up @@ -278,6 +281,68 @@ function slowHasUnequalTag(val1Tag, val1, val2) {
return val1Tag !== ObjectPrototypeToString(val2);
}

const kTemporalTagPrefix = '[object Temporal.';

const kTemporalDurationFields = [
'Years', 'Months', 'Weeks', 'Days', 'Hours', 'Minutes', 'Seconds',
'Milliseconds', 'Microseconds', 'Nanoseconds',
];

function createTemporalComparators() {
const temporal = require('internal/util/temporal');
const comparators = { __proto__: null };
if (!temporal.hasTemporal) {
return comparators;
}

const getDurationFields = [];
for (let i = 0; i < kTemporalDurationFields.length; i++) {
ArrayPrototypePush(getDurationFields,
temporal[`TemporalDurationPrototypeGet${kTemporalDurationFields[i]}`]);
}
comparators.Duration = {
__proto__: null,
isType: temporal.isTemporalDuration,
// Temporal.Duration has no `equals` method, and `Temporal.Duration.compare`
// balances units, so `PT1H` and `PT60M` would be considered equal.
equals(val1, val2) {
for (let i = 0; i < getDurationFields.length; i++) {
if (getDurationFields[i](val1) !== getDurationFields[i](val2)) {
return false;
}
}
return true;
},
};

const types = [
'Instant', 'PlainDate', 'PlainDateTime', 'PlainMonthDay', 'PlainTime',
'PlainYearMonth', 'ZonedDateTime',
];
for (let i = 0; i < types.length; i++) {
comparators[types[i]] = {
__proto__: null,
isType: temporal[`isTemporal${types[i]}`],
equals: temporal[`Temporal${types[i]}PrototypeEquals`],
};
}
return comparators;
}

// Temporal objects keep their state in internal slots and have no own
// properties, so the key comparison alone would consider any two Temporal
// objects of the same type equal. Only values whose type tag starts with
// `Temporal.` are brand checked, to keep the cost off other objects. A Temporal
// object with a removed or changed type tag is therefore compared by its keys.
function getTemporalComparator(tag, value) {
if (!StringPrototypeStartsWith(tag, kTemporalTagPrefix)) {
return undefined;
}
temporalComparators ??= createTemporalComparators();
const comparator = temporalComparators[StringPrototypeSlice(tag, kTemporalTagPrefix.length, -1)];
return comparator !== undefined && comparator.isType(value) ? comparator : undefined;
}

function objectComparisonStart(val1, val2, mode, memos) {
if (mode === kStrict) {
if (wellKnownConstructors.has(val1.constructor) ||
Expand Down Expand Up @@ -307,6 +372,7 @@ function objectComparisonStart(val1, val2, mode, memos) {
}

let val1Tag;
let temporalComparator;
if (val1[SymbolToStringTag] === undefined &&
(val1Tag = ObjectPrototypeToString(val1)) === '[object Object]') {
if (slowHasUnequalTag(val1Tag, val1, val2)) {
Expand Down Expand Up @@ -379,7 +445,7 @@ function objectComparisonStart(val1, val2, mode, memos) {
} else if (!isPartialUint8Array(new Uint8Array(val1), new Uint8Array(val2))) {
return false;
}
} else if (slowHasUnequalTag(val1Tag ?? ObjectPrototypeToString(val1), val1, val2) ||
} else if (slowHasUnequalTag(val1Tag ??= ObjectPrototypeToString(val1), val1, val2) ||
ArrayIsArray(val2) ||
isArrayBufferView(val2) ||
isSet(val2) ||
Expand All @@ -388,6 +454,10 @@ function objectComparisonStart(val1, val2, mode, memos) {
isRegExp(val2) ||
isAnyArrayBuffer(val2)) {
return false;
} else if ((temporalComparator = getTemporalComparator(val1Tag, val1)) !== undefined) {
if (!temporalComparator.isType(val2) || !temporalComparator.equals(val1, val2)) {
return false;
}
} else if (isError(val1)) {
// Do not compare the stack as it might differ even though the error itself
// is otherwise identical.
Expand Down Expand Up @@ -457,6 +527,7 @@ function objectComparisonStart(val1, val2, mode, memos) {
return false;
}
} else if (isBoxedPrimitive(val2) ||
getTemporalComparator(val1Tag, val2) !== undefined ||
isNativeError(val2) ||
val2 instanceof Error ||
isWeakMap(val1) ||
Expand Down
116 changes: 116 additions & 0 deletions lib/internal/util/temporal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use strict';

// This module is initialized in the pre-execution phase, before user code is
// run. Since the module namespace is not populated at snapshot time, this
// module should be not be imported with a destructuring pattern in a module
// header or used within top-level module code, as the properties may not yet
// be initialized. Either import this module lazily, or import the namespace
// object without destructuring and access its properties at the point of use.

// This module exposes the following exports:
// * `hasTemporal`, a boolean property indicating whether the Temporal builtin
// is present during initialization.
// * Typecheck methods for Temporal objects: `isTemporalDuration()`,
// `isTemporalInstant()` etc. If `hasTemporal` is false, then these methods
// will always return `false`. Note that these are much less performant than
// the native typecheck methods in internal/util/types, and should not be
// used in performance-critical paths.
// If `hasTemporal` is true, then this module also exports the following, in
// primordial style:
// * Temporal constructors: `TemporalDuration`, `TemporalInstant` etc.
// * Static methods on temporal constructors: `TemporalDurationCompare`,
// `TemporalDurationFrom` etc.
// * Temporal prototype properties, methods and getters:
// `TemporalDurationPrototypeAbs`, `TemporalDurationPrototypeGetBlank` etc.

// TODO(Renegade334): Remove typecheck methods once available via V8 API.
// Replace with primordials if/when temporal_rs becomes a mandatory V8
// build dependency and the harmony_temporal flag is removed.

const {
ArrayPrototypeForEach,
FunctionPrototypeCall,
ObjectEntries,
ObjectGetOwnPropertyDescriptors,
SafeMap,
StringPrototypeSubstring,
StringPrototypeToUpperCase,
globalThis,
uncurryThis,
} = primordials;

// The keys of this Map are the Temporal constructor names.
// The values are the names of the most lightweight brand-aware getter within
// each prototype, which can be called speculatively as a brand check.
const constructors = new SafeMap()
.set('Duration', 'nanoseconds')
.set('Instant', 'epochNanoseconds')
.set('PlainDate', 'calendarId')
.set('PlainDateTime', 'calendarId')
.set('PlainMonthDay', 'calendarId')
.set('PlainTime', 'nanosecond')
.set('PlainYearMonth', 'calendarId')
.set('ZonedDateTime', 'calendarId');

exports.initialize = () => {
const { Temporal } = globalThis;

if (Temporal === undefined) {
exports.hasTemporal = false;

for (const name of constructors.keys()) {
exports[`isTemporal${name}`] = () => false;
}

return;
}

exports.hasTemporal = true;

for (const { 0: name, 1: brandedGetter } of constructors) {
const constructor = Temporal[name];
exports[`Temporal${name}`] = constructor;

ArrayPrototypeForEach(
ObjectEntries(ObjectGetOwnPropertyDescriptors(constructor)),
({ 0: key, 1: desc }) => {
if (typeof key === 'string' && typeof desc.value === 'function') {
exports[`Temporal${name}${capitalizeKey(key)}`] = desc.value;
}
},
);

const descriptors = ObjectGetOwnPropertyDescriptors(constructor.prototype);
ArrayPrototypeForEach(
ObjectEntries(descriptors),
({ 0: key, 1: desc }) => {
if (typeof key !== 'string') return;

if ('get' in desc) {
exports[`Temporal${name}PrototypeGet${capitalizeKey(key)}`] = uncurryThis(desc.get);
} else {
exports[`Temporal${name}Prototype${capitalizeKey(key)}`] = typeof desc.value === 'function' ?
uncurryThis(desc.value) :
desc.value;
}
},
);

exports[`isTemporal${name}`] = makeTypeCheckMethod(descriptors[brandedGetter].get);
}
};

function makeTypeCheckMethod(getter) {
return (value) => {
try {
FunctionPrototypeCall(getter, value);
return true;
} catch {
return false;
}
};
}

function capitalizeKey(key) {
return `${StringPrototypeToUpperCase(key[0])}${StringPrototypeSubstring(key, 1)}`;
}
55 changes: 54 additions & 1 deletion test/parallel/test-assert-deep.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use strict';

const { mustCall, hasCrypto } = require('../common');
const { mustCall, hasCrypto, hasTemporal } = require('../common');
const assert = require('assert');
const util = require('util');
const vm = require('vm');
const { test } = require('node:test');
const defaultMsgStart = 'Expected values to be strictly deep-equal:\n';
const defaultMsgStartFull = `${defaultMsgStart}+ actual - expected`;
Expand Down Expand Up @@ -167,6 +168,58 @@ test('regexp', () => {
);
});

test('Temporal objects are compared by value', {
skip: !hasTemporal && 'Temporal is not available',
}, () => {
const instant = Temporal.Instant.from('2026-01-01T00:00:00Z');
const laterInstant = Temporal.Instant.from('2036-01-01T00:00:00Z');

assertDeepAndStrictEqual(instant, Temporal.Instant.fromEpochMilliseconds(instant.epochMilliseconds));
assertNotDeepOrStrict(instant, laterInstant);
assertNotDeepOrStrict({ at: instant }, { at: laterInstant });
assert.throws(() => assert.partialDeepStrictEqual({ at: instant, id: 1 }, { at: laterInstant }), {
code: 'ERR_ASSERTION',
});
assert.partialDeepStrictEqual({ at: instant, id: 1 }, { at: Temporal.Instant.from(instant) });

const pairs = [
['2026-01-01', '2026-01-02', Temporal.PlainDate],
['2026-01-01T10:00', '2026-01-01T10:01', Temporal.PlainDateTime],
['10:00', '10:00:00.000000001', Temporal.PlainTime],
['2026-01', '2026-02', Temporal.PlainYearMonth],
['01-01', '01-02', Temporal.PlainMonthDay],
['2026-01-01T00:00Z[UTC]', '2026-01-01T00:00Z[Europe/Brussels]', Temporal.ZonedDateTime],
];
for (const { 0: value, 1: otherValue, 2: Type } of pairs) {
assertDeepAndStrictEqual(Type.from(value), Type.from(value));
assertNotDeepOrStrict(Type.from(value), Type.from(otherValue));
}

assertNotDeepOrStrict(Temporal.PlainDate.from('2026-01-01'),
Temporal.PlainDate.from('2026-01-01[u-ca=gregory]'));
assertNotDeepOrStrict(instant, instant.toZonedDateTimeISO('UTC'));

assertDeepAndStrictEqual(Temporal.Duration.from({ hours: 1 }), Temporal.Duration.from('PT1H'));
assertNotDeepOrStrict(Temporal.Duration.from({ hours: 1 }), Temporal.Duration.from({ minutes: 60 }));
assertNotDeepOrStrict(Temporal.Duration.from({ hours: 1 }), Temporal.Duration.from({ hours: -1 }));

// Own properties are still compared.
assertNotDeepOrStrict(Object.assign(Temporal.Instant.from(instant), { id: 1 }), Temporal.Instant.from(instant));

// Temporal objects from another realm are compared by value as well. Their
// prototypes differ, which only deepStrictEqual compares.
const otherRealmInstant = vm.runInNewContext(`Temporal.Instant.from('${instant}')`);
assert.deepEqual(instant, otherRealmInstant);
assert.partialDeepStrictEqual(instant, otherRealmInstant);
assert.throws(() => assert.deepStrictEqual(instant, otherRealmInstant), { code: 'ERR_ASSERTION' });
assertNotDeepOrStrict(laterInstant, otherRealmInstant);

// An object that only carries the Temporal tag is not a Temporal object.
const lookalike = Object.defineProperty({}, Symbol.toStringTag, { value: 'Temporal.Instant' });
assertNotDeepOrStrict(instant, lookalike);
assertNotDeepOrStrict(lookalike, instant);
});

// For these weird cases, deepEqual should pass (at least for now),
// but deepStrictEqual should throw.
test('deepEqual should pass for these weird cases', () => {
Expand Down
1 change: 1 addition & 0 deletions test/parallel/test-bootstrap-modules.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ expected.beforePreExec = new Set([

expected.atRunTime = new Set([
'NativeModule internal/process/pre_execution',
'NativeModule internal/util/temporal',
]);

const { isMainThread } = require('worker_threads');
Expand Down
Loading