├── .gitmodules ├── .editorconfig ├── .gitignore ├── update_deps.sh ├── .github └── workflows │ ├── lint.yml │ ├── deploy.yml │ └── test.yml ├── refresh_polyfill_code.sh ├── spec.html ├── spec ├── biblio.json ├── temporal-biblio.json ├── timezone.html └── intl.html ├── package.json ├── LICENSE ├── .eslintrc.yml ├── polyfill ├── README.md └── polyfill.diff └── README.md /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "temporal"] 2 | path = temporal 3 | url = https://github.com/tc39/proposal-temporal.git 4 | branch = canonical-tz-polyfill 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.js] 13 | max_line_length = 120 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | polyfill/index.js 3 | polyfill/index.js.map 4 | polyfill/script.js 5 | polyfill/script.js.map 6 | polyfill/*.tgz 7 | polyfill/venv 8 | polyfill/coverage 9 | polyfill/script-instrumented.js 10 | out/ 11 | .vscode/ 12 | .eslintcache 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /update_deps.sh: -------------------------------------------------------------------------------- 1 | # root folder dependencies 2 | npx npm-check-updates -u 3 | npm install 4 | 5 | # polyfill dependencies 6 | # NOTE: we don't update demitasse because our tests aren't compatible with its latest version 7 | cd polyfill 8 | npx npm-check-updates -u -x @pipobscure/demitasse,@pipobscure/demitasse-pretty,@pipobscure/demitasse-run 9 | npm install 10 | cd .. 11 | 12 | # verify that builds still work 13 | npm run build 14 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Run linter 2 | on: pull_request 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - name: use node.js v19.x 9 | uses: actions/setup-node@v3 10 | with: 11 | node-version: 19.x 12 | - run: npm ci 13 | - run: npm run refresh-polyfill-ci 14 | - run: npm run lint 15 | - run: npm run build:spec 16 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | with: 12 | persist-credentials: false 13 | - name: use node.js v19.x 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 19.x 17 | - run: npm ci 18 | - run: npm run build:spec 19 | - uses: JamesIves/github-pages-deploy-action@3.7.1 20 | with: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | BRANCH: gh-pages 23 | FOLDER: out 24 | CLEAN: true 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | test-polyfill: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: use node.js v19.x 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 19.x 16 | - run: npm ci 17 | - run: npm run refresh-polyfill-ci 18 | - run: npm run test-demitasse 19 | env: 20 | HEAD_SHA: ${{ github.event.pull_request.head.sha }} 21 | test-test262: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | # we set up submidules manually in npm run refresh-polyfill-ci 26 | # with: 27 | # submodules: true 28 | - name: use node.js v19.x 29 | uses: actions/setup-node@v3 30 | with: 31 | node-version: 19.x 32 | - run: npm ci 33 | - run: npm run refresh-polyfill-ci 34 | - run: npm run test262 35 | env: 36 | HEAD_SHA: ${{ github.event.pull_request.head.sha }} 37 | -------------------------------------------------------------------------------- /refresh_polyfill_code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get latest contents of canonical-tz-polyfill branch of proposal-temporal 4 | git submodule update --init --remote --force --recursive 5 | 6 | # Submodules were updated to detached heads. Undetach them by switching to the 7 | # the correct branch for each. 8 | cd temporal 9 | git switch canonical-tz-polyfill 10 | cd polyfill/test262 11 | git switch proposal-canonical-tz-tests 12 | cd ../../.. 13 | 14 | # Regenerate the patch file of the (few) changes in this proposal's polyfill. 15 | # The polyfill changes are in one commit at HEAD of the submodule's branch. 16 | cd temporal 17 | git diff HEAD~1 > ../polyfill/polyfill.diff 18 | cd .. 19 | 20 | # If not in CI, build the polyfill to make sure it works 21 | # In CI, building will happen as part of testing. 22 | if [ -n "$CI" ]; then 23 | if [ -n "$(git status --porcelain)" ]; then 24 | # Uncommitted changes because latest diff isn't checked in 25 | echo "polyfill/polyfill.diff is out of date. Next steps:" 26 | echo "1. npm run refresh-polyfill" 27 | echo "2. Commit the new polyfill/polyfill.diff" 28 | exit 1 29 | fi 30 | else 31 | # When running outside CI, validate that the polyfill still builds 32 | npm run build 33 | fi 34 | -------------------------------------------------------------------------------- /spec.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
 4 | title: Time Zone Canonicalization proposal
 5 | stage: 2
 6 | contributors: Justin Grant, Richard Gibson
 7 | markEffects: true
 8 | 
9 | 10 | 11 | 12 | 13 |

Time Zone Canonicalization proposal

14 |

This proposal aims to:

15 | 19 | 20 |

This specification consists of two parts:

21 | 25 | 26 |

To provide feedback on this proposal, please visit the proposal repository at https://github.com/tc39/proposal-canonical-tz.

27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /spec/biblio.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "location": "https://tc39.es/ecma402/", 4 | "entries": [ 5 | { 6 | "type": "op", 7 | "aoid": "CanonicalizeLocaleList", 8 | "id": "sec-canonicalizelocalelist" 9 | }, 10 | { 11 | "type": "op", 12 | "aoid": "GetNumberOption", 13 | "id": "sec-getnumberoption" 14 | }, 15 | { 16 | "type": "op", 17 | "aoid": "ResolveLocale", 18 | "id": "sec-resolvelocale" 19 | }, 20 | { 21 | "type": "clause", 22 | "number": "Internal slots", 23 | "id": "sec-intl.datetimeformat-internal-slots" 24 | }, 25 | { 26 | "type": "clause", 27 | "number": "get Intl.DateTimeFormat.prototype.format", 28 | "id": "sec-intl.datetimeformat.prototype.format" 29 | }, 30 | { 31 | "type": "clause", 32 | "number": "6.1", 33 | "id": "sec-case-sensitivity-and-case-mapping" 34 | }, 35 | { 36 | "type": "term", 37 | "term": "ASCII-case-insensitive match", 38 | "id": "sec-case-sensitivity-and-case-mapping" 39 | }, 40 | { 41 | "type": "term", 42 | "term": "ASCII-lowercase", 43 | "id": "sec-case-sensitivity-and-case-mapping" 44 | }, 45 | { 46 | "type": "term", 47 | "term": "%DateTimeFormat%", 48 | "id": "sec-intl-datetimeformat-constructor" 49 | }, 50 | { 51 | "type": "clause", 52 | "number": "Table 6", 53 | "id": "table-datetimeformat-components" 54 | }, 55 | { 56 | "type": "op", 57 | "aoid": "PartitionPattern", 58 | "id": "sec-partitionpattern" 59 | } 60 | ] 61 | } 62 | ] 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "proposal-canonical-tz", 4 | "version": "1.0.0", 5 | "description": "TC39 Proposal (stacked on Temporal) to improve handling of changes to the IANA Time Zone Database.", 6 | "engines": { 7 | "node": ">=12.16.0" 8 | }, 9 | "main": "polyfill/lib/index.mjs", 10 | "devDependencies": { 11 | "@tc39/ecma262-biblio": "=2.1.2589", 12 | "@typescript-eslint/eslint-plugin": "^5.60.0", 13 | "@typescript-eslint/parser": "^5.60.0", 14 | "ecmarkup": "^17.0.1", 15 | "eslint": "^8.43.0", 16 | "eslint-config-prettier": "^8.8.0", 17 | "eslint-plugin-prettier": "^4.2.1", 18 | "mkdirp": "^3.0.1", 19 | "prettier": "^2.8.8", 20 | "typescript": "^5.1.3" 21 | }, 22 | "scripts": { 23 | "test": "cd temporal/polyfill && npm install && npm test && npm run test262", 24 | "codecov:test262": "cd temporal/polyfill && npm install && npm run codecov:test262", 25 | "test-demitasse": "cd temporal/polyfill && npm install && npm run test", 26 | "test262": "cd temporal/polyfill && npm install && npm run test262", 27 | "lint": "cd temporal && npm run lint", 28 | "build:polyfill": "cd temporal/polyfill && npm install && npm run build", 29 | "prebuild:spec": "mkdirp out", 30 | "build:spec": "ecmarkup --lint-spec --strict --load-biblio @tc39/ecma262-biblio spec.html out/index.html", 31 | "pretty": "eslint . --ext js,mjs,.d.ts --fix", 32 | "build": "npm run build:polyfill && npm run build:spec", 33 | "refresh-polyfill": "./refresh_polyfill_code.sh", 34 | "refresh-polyfill-ci": "CI=1 ./refresh_polyfill_code.sh", 35 | "update": "./update_deps.sh" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/justingrant/proposal-canonical-tz.git" 40 | }, 41 | "author": "Justin Grant ", 42 | "license": "BSD-3-Clause", 43 | "prettier": { 44 | "printWidth": 120, 45 | "trailingComma": "none", 46 | "tabWidth": 2, 47 | "semi": true, 48 | "singleQuote": true, 49 | "bracketSpacing": true, 50 | "arrowParens": "always" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, 2018, 2019, 2020 2 | Ecma International. All rights reserved. 3 | 4 | All Software contained in this document ("Software") is protected by copyright 5 | and is being made available under the "BSD License", included below. 6 | 7 | This Software may be subject to third party rights (rights from parties other 8 | than Ecma International), including patent rights, and no licenses under such 9 | third party rights are granted under this license even if the third party 10 | concerned is a member of Ecma International. 11 | 12 | SEE THE ECMA CODE OF CONDUCT IN PATENT MATTERS AVAILABLE AT 13 | https://ecma-international.org/memento/codeofconduct.htm 14 | FOR INFORMATION REGARDING THE LICENSING OF PATENT CLAIMS THAT ARE REQUIRED TO 15 | IMPLEMENT ECMA INTERNATIONAL STANDARDS. 16 | 17 | 18 | Redistribution and use in source and binary forms, with or without 19 | modification, are permitted provided that the following conditions are met: 20 | 21 | 1. Redistributions of source code must retain the above copyright notice, this 22 | list of conditions and the following disclaimer. 23 | 24 | 2. Redistributions in binary form must reproduce the above copyright notice, 25 | this list of conditions and the following disclaimer in the documentation 26 | and/or other materials provided with the distribution. 27 | 28 | 3. Neither the name of the authors nor Ecma International may be used to 29 | endorse or promote products derived from this software without specific prior 30 | written permission. 31 | 32 | THIS SOFTWARE IS PROVIDED BY THE ECMA INTERNATIONAL "AS IS" AND 33 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 34 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 35 | ARE DISCLAIMED. IN NO EVENT SHALL ECMA INTERNATIONAL BE LIABLE 36 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 37 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 38 | OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 39 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 40 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 41 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 42 | SUCH DAMAGE. 43 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es6: true 4 | node: true 5 | plugins: 6 | - prettier 7 | - '@typescript-eslint' 8 | extends: 9 | - 'eslint:recommended' 10 | - 'plugin:prettier/recommended' 11 | - 'plugin:@typescript-eslint/recommended' 12 | parser: '@typescript-eslint/parser' # for index.d.ts 13 | globals: 14 | Atomics: readonly 15 | BigInt: readonly 16 | SharedArrayBuffer: readonly 17 | globalThis: readonly 18 | parserOptions: 19 | ecmaVersion: 2020 20 | sourceType: module 21 | ignorePatterns: 22 | - node_modules/ 23 | - /out/ 24 | - /polyfill/dist/ 25 | - /polyfill/test262/ 26 | - /polyfill/venv/ 27 | - /polyfill/coverage/ 28 | # Specific generated files 29 | - /polyfill/script.js 30 | rules: 31 | array-element-newline: 32 | - error 33 | - consistent 34 | arrow-parens: error 35 | arrow-spacing: error 36 | brace-style: 37 | - error 38 | - 1tbs 39 | comma-dangle: error 40 | comma-spacing: error 41 | curly: 42 | - error 43 | - multi-line 44 | func-call-spacing: error 45 | function-call-argument-newline: 46 | - error 47 | - consistent 48 | indent: 49 | - error 50 | - 2 51 | - SwitchCase: 1 52 | keyword-spacing: error 53 | max-len: 54 | - error 55 | - code: 120 56 | ignoreRegExpLiterals: true 57 | no-alert: error 58 | no-console: error 59 | no-multiple-empty-lines: 60 | - error 61 | - max: 1 62 | no-trailing-spaces: error 63 | object-curly-spacing: 64 | - error 65 | - always 66 | object-property-newline: 67 | - error 68 | - allowAllPropertiesOnSameLine: true 69 | quote-props: 70 | - error 71 | - as-needed 72 | quotes: 73 | - error 74 | - single 75 | - avoidEscape: true 76 | semi: error 77 | space-infix-ops: error 78 | '@typescript-eslint/explicit-module-boundary-types': off 79 | '@typescript-eslint/no-empty-function': off 80 | '@typescript-eslint/no-var-requires': off 81 | '@typescript-eslint/ban-ts-comment': off 82 | overrides: 83 | - files: 84 | - docs/cookbook/fromLegacyDateOnly.mjs 85 | rules: 86 | prettier/prettier: 'off' 87 | - files: 88 | - docs/buildDocs.js 89 | - polyfill/lib/duration.mjs 90 | - polyfill/lib/init.js 91 | - polyfill/test/all.mjs 92 | - polyfill/test/validStrings.mjs 93 | rules: 94 | no-console: off 95 | -------------------------------------------------------------------------------- /spec/temporal-biblio.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "location": "https://tc39.es/proposal-temporal/", 4 | "entries": [ 5 | { 6 | "type": "op", 7 | "aoid": "GetAvailableNamedTimeZoneIdentifier", 8 | "id": "sec-getavailablenamedtimezoneidentifier" 9 | }, 10 | { 11 | "type": "op", 12 | "aoid": "IsOffsetTimeZoneIdentifier", 13 | "id": "sec-temporal-isoffsettimezoneidentifier" 14 | }, 15 | { 16 | "type": "op", 17 | "aoid": "FormatOffsetTimeZoneIdentifier", 18 | "id": "sec-temporal-formatoffsettimezoneidentifier" 19 | }, 20 | { 21 | "type": "op", 22 | "aoid": "ParseDateTimeUTCOffset", 23 | "id": "sec-temporal-parsedatetimeutcoffset" 24 | }, 25 | { 26 | "type": "op", 27 | "aoid": "ParseTimeZoneIdentifier", 28 | "id": "sec-parsetimezoneidentifier" 29 | }, 30 | { 31 | "type": "op", 32 | "aoid": "ObjectImplementsTemporalTimeZoneProtocol", 33 | "id": "sec-temporal-objectimplementstemporaltimezoneprotocol" 34 | }, 35 | { 36 | "type": "op", 37 | "aoid": "ParseTemporalTimeZoneString", 38 | "id": "sec-parsetemporaltimezonestring" 39 | }, 40 | { 41 | "type": "op", 42 | "aoid": "ToTemporalTimeZoneIdentifier", 43 | "id": "sec-temporal-totemporaltimezoneidentifier" 44 | }, 45 | { 46 | "type": "op", 47 | "aoid": "ToTemporalCalendarIdentifier", 48 | "id": "sec-temporal-totemporalcalendaridentifier" 49 | }, 50 | { 51 | "type": "op", 52 | "aoid": "CreateTemporalInstant", 53 | "id": "sec-temporal-createtemporalinstant" 54 | }, 55 | { 56 | "type": "op", 57 | "aoid": "FormatDateTime", 58 | "id": "sec-formatdatetime" 59 | }, 60 | { 61 | "type": "op", 62 | "aoid": "GetDateTimeFormatPattern", 63 | "id": "sec-getdatetimeformatpattern" 64 | }, 65 | { 66 | "type": "op", 67 | "aoid": "GetOption", 68 | "id": "sec-getoption" 69 | }, 70 | { 71 | "type": "clause", 72 | "number": "Supported fields for Temporal patterns", 73 | "id": "table-temporal-patterns" 74 | }, 75 | { 76 | "type": "clause", 77 | "number": "Temporal.ZonedDateTime.prototype.toLocaleString", 78 | "id": "sec-temporal.zoneddatetime.prototype.tolocalestring" 79 | } 80 | ] 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /polyfill/README.md: -------------------------------------------------------------------------------- 1 | # Polyfill (for testing only) for TC39 Time Zone Canonicalization proposal 2 | 3 | This polyfill is only for testing the TC39 [Time Zone Canonicalization proposal](https://github.com/tc39/proposal-canonical-tz). 4 | The sole purpose of this polyfill is to run [Test262](https://github.com/tc39/test262) tests. 5 | DO NOT use it in production! 6 | 7 | Like this proposal's spec text, the polyfill is stacked on top of the Temporal proposal's polyfill. 8 | 9 | For ease of development, this proposal's polyfill changes—about 20 lines—live in a single commit at the top of the [canonical-tz-polyfill](https://github.com/tc39/proposal-temporal/commits/canonical-tz-polyfill) branch of the Temporal proposal repo. 10 | These changes are also checked into this repo as [polyfill.diff](./polyfill.diff) in the same directory as this README. 11 | 12 | ## Testing this proposal 13 | 14 | Test262 tests are available at [tc39/test262#3837](https://github.com/tc39/test262/pull/3837), although this PR is not expected to be merged unless this proposal reaches Stage 3. 15 | 16 | ### Test roadmap 17 | 18 | - [x] DONE Add lightweight "Demitasse" tests in [test/canonicaltz.mjs](./test/canonicaltz.mjs) that cover this proposal's full surface area 19 | - [x] DONE Fix 15 existing Test262 tests that were broken by this proposal, because they assumed that time zone identifiers are always canonicalized 20 | - [x] DONE Open a Test262 draft PR 21 | - [x] DONE Migrate `Temporal.TimeZone.p.equals` (the only new API in this proposal) Demitasse tests to Test262 22 | - [x] DONE Migrate `Temporal.TimeZone` Demitasse tests to Test262 23 | - [x] DONE Migrate `Temporal.ZonedDateTime` Demitasse tests to Test262 24 | - [x] DONE Migrate `Intl.DateTimeFormat` Demitasse tests to Test262 25 | - [ ] Remove Demitasse tests from this repo and from CI workflows 26 | 27 | ### How to run tests 28 | 29 | Before running tests, the polyfill code from the proposal-temporal branch linked above must be current in the `temporal` submodule in this repo. 30 | To do this, use `npm run refresh-polyfill` from the root of this repo. 31 | This script will also regenerate `polyfill.diff`, which should be checked in after any polyfill changes are pushed. 32 | 33 | Once the polyfill code is updated, use `npm test` to run tests. 34 | To validate that this proposal doesn't break anything in Temporal, all 6000+ Temporal tests are run in addition to tests added or changed by this proposal. 35 | It takes 1-2 minutes on a fast machine to run all these tests using [`@js-temporal/temporal-test262-runner`](https://www.npmjs.com/package/@js-temporal/temporal-test262-runner). 36 | 37 | In CI, `npm run refresh-polyfill-ci` must be run before running tests. This script acts the same as `npm run refresh-polyfill` except: 38 | * If `polyfill.diff` is out of date, it fails with an error because we don't want PRs to be merged with an outdated polyfill diff 39 | * It won't build the polyfill because building happens later as part of testing scripts 40 | 41 | ## Production polyfill plans 42 | 43 | There is no plan to provide a separate production polyfill for this proposal, because it's so tightly integrated with Temporal. 44 | Instead, if this proposal makes it to Stage 4, we'll work with the maintainers of Temporal polyfills to ensure that the small number of changes in this proposal are integrated into those polyfills. 45 | -------------------------------------------------------------------------------- /polyfill/polyfill.diff: -------------------------------------------------------------------------------- 1 | diff --git a/.gitmodules b/.gitmodules 2 | index 1c6831c7..73008e46 100644 3 | --- a/.gitmodules 4 | +++ b/.gitmodules 5 | @@ -1,3 +1,4 @@ 6 | [submodule "polyfill/test262"] 7 | path = polyfill/test262 8 | - url = https://github.com/tc39/test262 9 | + url = https://github.com/justingrant/test262 10 | + branch = proposal-canonical-tz-tests 11 | diff --git a/polyfill/index.d.ts b/polyfill/index.d.ts 12 | index 784677c9..b11758ea 100644 13 | --- a/polyfill/index.d.ts 14 | +++ b/polyfill/index.d.ts 15 | @@ -1138,6 +1138,7 @@ export namespace Temporal { 16 | static from(timeZone: TimeZoneLike): Temporal.TimeZone | TimeZoneProtocol; 17 | constructor(timeZoneIdentifier: string); 18 | readonly id: string; 19 | + equals(timeZone: TimeZoneLike): boolean; 20 | getOffsetNanosecondsFor(instant: Temporal.Instant | string): number; 21 | getOffsetStringFor(instant: Temporal.Instant | string): string; 22 | getPlainDateTimeFor(instant: Temporal.Instant | string, calendar?: CalendarLike): Temporal.PlainDateTime; 23 | diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs 24 | index e7f04c0b..0aa7ef19 100644 25 | --- a/polyfill/lib/ecmascript.mjs 26 | +++ b/polyfill/lib/ecmascript.mjs 27 | @@ -2101,7 +2101,7 @@ export function ToTemporalTimeZoneSlotValue(temporalTimeZoneLike) { 28 | 29 | const record = GetAvailableNamedTimeZoneIdentifier(tzName); 30 | if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`); 31 | - return record.primaryIdentifier; 32 | + return record.identifier; 33 | } 34 | if (z) return 'UTC'; 35 | // if !tzName && !z then offset must be present 36 | @@ -2126,7 +2126,23 @@ export function TimeZoneEquals(one, two) { 37 | if (one === two) return true; 38 | const tz1 = ToTemporalTimeZoneIdentifier(one); 39 | const tz2 = ToTemporalTimeZoneIdentifier(two); 40 | - return tz1 === tz2; 41 | + if (tz1 === tz2) return true; 42 | + const offsetNs1 = ParseTimeZoneIdentifier(tz1).offsetNanoseconds; 43 | + const offsetNs2 = ParseTimeZoneIdentifier(tz2).offsetNanoseconds; 44 | + if (offsetNs1 === undefined && offsetNs2 === undefined) { 45 | + // It's costly to call GetAvailableNamedTimeZoneIdentifier, so (unlike the 46 | + // spec) the polyfill will early-return if one of them isn't recognized. Try 47 | + // the second ID first because it's more likely to be unknown, because it 48 | + // can come from the argument of TimeZone.p.equals as opposed to the first 49 | + // ID which comes from the receiver. 50 | + const idRecord2 = GetAvailableNamedTimeZoneIdentifier(tz2); 51 | + if (!idRecord2) return false; 52 | + const idRecord1 = GetAvailableNamedTimeZoneIdentifier(tz1); 53 | + if (!idRecord1) return false; 54 | + return idRecord1.primaryIdentifier === idRecord2.primaryIdentifier; 55 | + } else { 56 | + return offsetNs1 === offsetNs2; 57 | + } 58 | } 59 | 60 | export function TemporalDateTimeToDate(dateTime) { 61 | diff --git a/polyfill/lib/intl.mjs b/polyfill/lib/intl.mjs 62 | index 9d78ee43..d86269a5 100644 63 | --- a/polyfill/lib/intl.mjs 64 | +++ b/polyfill/lib/intl.mjs 65 | @@ -139,7 +139,7 @@ Object.defineProperty(DateTimeFormat, 'prototype', { 66 | 67 | function resolvedOptions() { 68 | const resolved = this[ORIGINAL].resolvedOptions(); 69 | - resolved.timeZone = this[TZ_CANONICAL]; 70 | + resolved.timeZone = this[TZ_ORIGINAL]; 71 | return resolved; 72 | } 73 | 74 | diff --git a/polyfill/lib/timezone.mjs b/polyfill/lib/timezone.mjs 75 | index 7c6c8a2e..857385ad 100644 76 | --- a/polyfill/lib/timezone.mjs 77 | +++ b/polyfill/lib/timezone.mjs 78 | @@ -33,7 +33,7 @@ export class TimeZone { 79 | } else { 80 | const record = ES.GetAvailableNamedTimeZoneIdentifier(stringIdentifier); 81 | if (!record) throw new RangeError(`Invalid time zone identifier: ${stringIdentifier}`); 82 | - stringIdentifier = record.primaryIdentifier; 83 | + stringIdentifier = record.identifier; 84 | } 85 | CreateSlots(this); 86 | SetSlot(this, TIMEZONE_ID, stringIdentifier); 87 | @@ -51,6 +51,11 @@ export class TimeZone { 88 | if (!ES.IsTemporalTimeZone(this)) throw new TypeError('invalid receiver'); 89 | return GetSlot(this, TIMEZONE_ID); 90 | } 91 | + equals(other) { 92 | + if (!ES.IsTemporalTimeZone(this)) throw new TypeError('invalid receiver'); 93 | + const timeZoneSlotValue = ES.ToTemporalTimeZoneSlotValue(other); 94 | + return ES.TimeZoneEquals(this, timeZoneSlotValue); 95 | + } 96 | getOffsetNanosecondsFor(instant) { 97 | if (!ES.IsTemporalTimeZone(this)) throw new TypeError('invalid receiver'); 98 | instant = ES.ToTemporalInstant(instant); 99 | diff --git a/polyfill/lib/zoneddatetime.mjs b/polyfill/lib/zoneddatetime.mjs 100 | index cbd4f8c8..ed1a4143 100644 101 | --- a/polyfill/lib/zoneddatetime.mjs 102 | +++ b/polyfill/lib/zoneddatetime.mjs 103 | @@ -478,7 +478,7 @@ export class ZonedDateTime { 104 | } else { 105 | const record = ES.GetAvailableNamedTimeZoneIdentifier(timeZoneIdentifier); 106 | if (!record) throw new RangeError(`toLocaleString formats built-in time zones, not ${timeZoneIdentifier}`); 107 | - optionsCopy.timeZone = record.primaryIdentifier; 108 | + optionsCopy.timeZone = record.identifier; 109 | } 110 | 111 | const formatter = new DateTimeFormat(locales, optionsCopy); 112 | diff --git a/polyfill/test262 b/polyfill/test262 113 | index c5b24c64..c472ab5e 160000 114 | --- a/polyfill/test262 115 | +++ b/polyfill/test262 116 | @@ -1 +1 @@ 117 | -Subproject commit c5b24c64c3c27544f15e1c18ef274924cff1e32c 118 | +Subproject commit c472ab5e2d0047015b90d003828507321a8f7eb6 119 | -------------------------------------------------------------------------------- /spec/timezone.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Amendments to the ECMAScript® 2024 Language Specification

6 | 7 | 8 |

9 | This section lists amendments which must be made to ECMA-262, the ECMAScript® 2024 Language Specification. 10 |

11 |

12 | This text is based on https://github.com/tc39/proposal-temporal/blob/main/spec/timezone.html, which specifies the timezone-related changes to ECMA-262 made by the Temporal proposal. 13 | Text to be added is marked like this, and text to be deleted is marked like this. 14 |

15 |

16 | The changes below are limited to the %Temporal.TimeZone% built-in object and the abstract operations related to that object. 17 |

18 |
19 | 20 | 21 |

Temporal.TimeZone Objects

22 | 23 | 24 |

Temporal.TimeZone ( _identifier_ )

25 |

26 | This function performs the following steps when called: 27 |

28 | 29 | 1. If NewTarget is *undefined*, then 30 | 1. Throw a *TypeError* exception. 31 | 1. Set _identifier_ to ? ToString(_identifier_). 32 | 1. Let _parseResult_ be ? ParseTimeZoneIdentifier(_identifier_). 33 | 1. If _parseResult_.[[OffsetNanoseconds]] is not ~empty~, then 34 | 1. Set _identifier_ to FormatOffsetTimeZoneIdentifier(_parseResult_.[[OffsetNanoseconds]], ~separated~). 35 | 1. Else, 36 | 1. Let _timeZoneIdentifierRecord_ be GetAvailableNamedTimeZoneIdentifier(_identifier_). 37 | 1. If _timeZoneIdentifierRecord_ is ~empty~, throw a *RangeError* exception. 38 | 1. Set _identifier_ to _timeZoneIdentifierRecord_.[[PrimaryIdentifierIdentifier]]. 39 | 1. Return ? CreateTemporalTimeZone(_identifier_, NewTarget). 40 | 41 |
42 | 43 | 44 | 45 |

Temporal.TimeZone.prototype.equals ( _timeZoneLike_ )

46 |

47 | This method performs the following steps when called: 48 |

49 | 50 | 1. Let _timeZone_ be the *this* value. 51 | 1. Perform ? RequireInternalSlot(_timeZone_, [[InitializedTemporalTimeZone]]). 52 | 1. Let _other_ be ? ToTemporalTimeZoneSlotValue(_timeZoneLike_). 53 | 1. Return ? TimeZoneEquals(_timeZone_, _other_). 54 | 55 |
56 |
57 | 58 | 59 |

Abstract operations

60 | 61 | 62 |

63 | CreateTemporalTimeZone ( 64 | _identifier_: a String, 65 | optional _newTarget_: a constructor, 66 | ): either a normal completion containing a Temporal.TimeZone, or an abrupt completion 67 |

68 |
69 |
description
70 |
It creates a new Temporal.TimeZone instance and fills the internal slots with valid values.
71 |
72 | 73 | 1. If _newTarget_ is not present, set _newTarget_ to %Temporal.TimeZone%. 74 | 1. Let _object_ be ? OrdinaryCreateFromConstructor(_newTarget_, *"%Temporal.TimeZone.prototype%"*, « [[InitializedTemporalTimeZone]], [[Identifier]], [[OffsetNanoseconds]] »). 75 | 1. Assert: _identifier_ is an available named time zone identifier or an offset time zone identifier. 76 | 1. Let _parseResult_ be ! ParseTimeZoneIdentifier(_identifier_). 77 | 1. If _parseResult_.[[OffsetNanoseconds]] is not ~empty~, then 78 | 1. Set _object_.[[Identifier]] to ~empty~. 79 | 1. Set _object_.[[OffsetNanoseconds]] to _parseResult_.[[OffsetNanoseconds]]. 80 | 1. Else, 81 | 1. Assert: _parseResult_.[[Name]] is not ~empty~. 82 | 1. Assert: GetAvailableNamedTimeZoneIdentifier(_identifier_).[[PrimaryIdentifierIdentifier]] is _identifier_. 83 | 1. Set _object_.[[Identifier]] to _identifier_. 84 | 1. Set _object_.[[OffsetNanoseconds]] to ~empty~. 85 | 1. Return _object_. 86 | 87 |
88 | 89 | 90 |

91 | ToTemporalTimeZoneSlotValue ( 92 | _temporalTimeZoneLike_: an ECMAScript value, 93 | ): either a normal completion containing either a String or an Object, or a throw completion 94 |

95 |
96 |
description
97 |
It attempts to derive a value from _temporalTimeZoneLike_ that is suitable for storing in a Temporal.ZonedDateTime's [[TimeZone]] internal slot, and returns that value if found or throws an exception if not.
98 |
99 | 100 | 1. If Type(_temporalTimeZoneLike_) is Object, then 101 | 1. If _temporalTimeZoneLike_ has an [[InitializedTemporalZonedDateTime]] internal slot, then 102 | 1. Return _temporalTimeZoneLike_.[[TimeZone]]. 103 | 1. If ? ObjectImplementsTemporalTimeZoneProtocol(_temporalTimeZoneLike_) is *false*, throw a *TypeError* exception. 104 | 1. Return _temporalTimeZoneLike_. 105 | 1. Let _identifier_ be ? ToString(_temporalTimeZoneLike_). 106 | 1. Let _parseResult_ be ? ParseTemporalTimeZoneString(_identifier_). 107 | 1. If _parseResult_.[[Name]] is not *undefined*, then 108 | 1. Let _name_ be _parseResult_.[[Name]]. 109 | 1. Let _offsetNanoseconds_ be ? ParseTimeZoneIdentifier(_name_).[[OffsetNanoseconds]]. 110 | 1. If _offsetNanoseconds_ is not ~empty~, return FormatOffsetTimeZoneIdentifier(_offsetNanoseconds_, ~separated~). 111 | 1. Let _timeZoneIdentifierRecord_ be GetAvailableNamedTimeZoneIdentifier(_name_). 112 | 1. If _timeZoneIdentifierRecord_ is ~empty~, throw a *RangeError* exception. 113 | 1. Return _timeZoneIdentifierRecord_.[[PrimaryIdentifierIdentifier]]. 114 | 1. If _parseResult_.[[Z]] is *true*, return *"UTC"*. 115 | 1. Let _offsetParseResult_ be ! ParseDateTimeUTCOffset(_parseResult_.[[OffsetString]]). 116 | 1. If _offsetParseResult_.[[HasSubMinutePrecision]] is *true*, throw a *RangeError* exception. 117 | 1. Return FormatOffsetTimeZoneIdentifier(_offsetParseResult_.[[OffsetNanoseconds]], ~separated~). 118 | 119 |
120 | 121 | 122 |

123 | TimeZoneEquals ( 124 | _one_: a String or Object, 125 | _two_: a String or Object, 126 | ): either a normal completion containing either *true* or *false*, or a throw completion 127 |

128 |
129 |
description
130 |
It returns *true* if its arguments represent time zones using the same identifierthe same time zones, either because their identifiers are equal or because those identifiers resolve to the same primary time zone identifier or UTC offset.
131 |
132 | 133 | 1. If _one_ and _two_ are the same Object value, return *true*. 134 | 1. Let _timeZoneOne_ be ? ToTemporalTimeZoneIdentifier(_one_). 135 | 1. Let _timeZoneTwo_ be ? ToTemporalTimeZoneIdentifier(_two_). 136 | 1. If _timeZoneOne_ is _timeZoneTwo_, return *true*. 137 | 1. Let _offsetNanosecondsOne_ be ? ParseTimeZoneIdentifier(_timeZoneOne_).[[OffsetNanoseconds]]. 138 | 1. Let _offsetNanosecondsTwo_ be ? ParseTimeZoneIdentifier(_timeZoneTwo_).[[OffsetNanoseconds]]. 139 | 1. If _offsetNanosecondsOne_ is ~empty~ and _offsetNanosecondsTwo_ is ~empty~, then 140 | 1. Let _recordOne_ be GetAvailableNamedTimeZoneIdentifier(_timeZoneOne_). 141 | 1. Let _recordTwo_ be GetAvailableNamedTimeZoneIdentifier(_timeZoneTwo_). 142 | 1. If _recordOne_ is not ~empty~ and _recordTwo_ is not ~empty~ and _recordOne_.[[PrimaryIdentifier]] is _recordTwo_.[[PrimaryIdentifier]], return *true*. 143 | 1. Else, 144 | 1. If _offsetNanosecondsOne_ is not ~empty~ and _offsetNanosecondsTwo_ is not ~empty~ and _offsetNanosecondsOne_ = _offsetNanosecondsTwo_, return *true*. 145 | 1. Return *false*. 146 | 147 |
148 |
149 |
150 |
151 | -------------------------------------------------------------------------------- /spec/intl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Amendments to the ECMAScript® 2024 Internationalization API Specification

6 | 7 | 8 |

9 | This section lists amendments which must be made to ECMA-402, the ECMAScript® 2024 Internationalization API Specification. 10 |

11 |

12 | This text is based on https://github.com/tc39/proposal-temporal/blob/main/spec/intl.html, which specifies the changes to ECMA-402 made by the Temporal proposal. 13 | Text to be added is marked like this, and text to be deleted is marked like this. 14 |

15 |
16 | 17 | 18 |

Use of the IANA Time Zone Database

19 | 20 | 21 |

22 | This proposal adds a note to the Temporal proposal's Use of the IANA Time Zone Database section, which replaces the current Time Zone Names section in ECMA-402. 23 |

24 |

25 | One paragraph of existing text above and below the inserted note is included for context. 26 |

27 |
28 | 29 |

[...]

30 | 31 |

32 | The IANA Time Zone Database is typically updated between five and ten times per year. 33 | These updates may add new Zone or Link names, may change Zones to Links, and may change the UTC offsets and transitions associated with any Zone. 34 | ECMAScript implementations are recommended to include updates to the IANA Time Zone Database as soon as possible. 35 | Such prompt action ensures that ECMAScript programs can accurately perform time-zone-sensitive calculations and can use newly-added available named time zone identifiers supplied by external input or the host environment. 36 |

37 | 38 | 39 | 40 |

41 | Although the IANA Time Zone Database maintainers strive for stability, in rare cases (averaging less than once per year) a Zone may be replaced by a new Zone. 42 | For example, in 2022 "*Europe/Kiev*" was deprecated to a Link resolving to a new "*Europe/Kyiv*" Zone. 43 |

44 |

45 | To reduce disruption from renaming changes, ECMAScript implementations are encouraged to initially add the new Zone as a non-primary time zone identifier that resolves to the current primary identifier. 46 | Then, after a waiting period, implementations are recommended to promote the new Zone to a primary time zone identifier while simultaneously demoting the deprecated name to non-primary. 47 | The recommended waiting period is two years after the IANA Time Zone Database release containing the changes. 48 | This delay allows other systems, that ECMAScript programs may interact with, to be updated to recognize the new Zone. 49 |

50 |

51 | A waiting period should only apply when a new Zone is added to replace an existing Zone. 52 | If an existing Zone and Link are swapped, then no waiting period is necessary. 53 |

54 |
55 |
56 | 57 |

58 | If implementations revise time zone information during the lifetime of an agent, then which identifiers are supported, the primary time zone identifier associated with any identifier, and the UTC offsets and transitions associated with any Zone, must be consistent with results previously observed by that agent. 59 | Due to the complexity of supporting this requirement, it is recommended that implementations maintain a fully consistent copy of the IANA Time Zone Database for the lifetime of each agent. 60 |

61 | 62 |

[...]

63 |
64 | 65 | 66 |

Temporal.ZonedDateTime.prototype.toLocaleString ( [ _locales_ [ , _options_ ] ] )

67 |

This definition supersedes the definition provided in .

68 |

69 | This method performs the following steps when called: 70 |

71 | 72 | 1. Let _zonedDateTime_ be the *this* value. 73 | 1. Perform ? RequireInternalSlot(_zonedDateTime_, [[InitializedTemporalZonedDateTime]]). 74 | 1. Let _dateTimeFormat_ be ! OrdinaryCreateFromConstructor(%DateTimeFormat%, %DateTimeFormat.prototype%, « [[InitializedDateTimeFormat]], [[Locale]], [[Calendar]], [[NumberingSystem]], [[TimeZone]], [[Weekday]], [[Era]], [[Year]], [[Month]], [[Day]], [[DayPeriod]], [[Hour]], [[Minute]], [[Second]], [[FractionalSecondDigits]], [[TimeZoneName]], [[HourCycle]], [[Pattern]], [[BoundFormat]] »). 75 | 1. Let _timeZone_ be ? ToTemporalTimeZoneIdentifier(_zonedDateTime_.[[TimeZone]]). 76 | 1. If IsOffsetTimeZoneIdentifier(_timeZone_) is *true*, throw a *RangeError* exception. 77 | 1. Let _timeZoneIdentifierRecord_ be GetAvailableNamedTimeZoneIdentifier(_timeZone_). 78 | 1. If _timeZoneIdentifierRecord_ is ~empty~, throw a *RangeError* exception. 79 | 1. Set _timeZone_ to _timeZoneIdentifierRecord_.[[PrimaryIdentifierIdentifier]]. 80 | 1. Perform ? InitializeDateTimeFormat(_dateTimeFormat_, _locales_, _options_, _timeZone_). 81 | 1. Let _calendar_ be ? ToTemporalCalendarIdentifier(_zonedDateTime_.[[Calendar]]). 82 | 1. If _calendar_ is not *"iso8601"* and not equal to _dateTimeFormat_.[[Calendar]], then 83 | 1. Throw a *RangeError* exception. 84 | 1. Let _instant_ be ! CreateTemporalInstant(_zonedDateTime_.[[Nanoseconds]]). 85 | 1. Return ? FormatDateTime(_dateTimeFormat_, _instant_). 86 | 87 |
88 | 89 | 90 |

InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ [ , _toLocaleStringTimeZone_ ] )

91 | 92 | 93 |

94 | This text is based on the revisions to InitializeDateTimeFormat made by the Temporal proposal, not the current text in ECMA-402. 95 |

96 |

97 | Only one line is changed and is marked like this. 98 | The rest of the text has been included for context, but it can be ignored. 99 |

100 |
101 | 102 |

103 | The abstract operation InitializeDateTimeFormat accepts the arguments _dateTimeFormat_ (which must be an object), _locales_, and _options_. 104 | It initializes _dateTimeFormat_ as a DateTimeFormat object. 105 | If an additional _toLocaleStringTimeZone_ argument is provided (which, if present, must be a canonical time zone name string), the time zone will be overridden and some adjustments will be made to the defaults in order to implement the behaviour of `Temporal.ZonedDateTime.prototype.toLocaleString`. 106 | This abstract operation functions as follows: 107 |

108 | 109 |

110 | The following algorithm refers to the `type` nonterminal from UTS 35's Unicode Locale Identifier grammar. 111 |

112 | 113 | 114 | 1. Let _requestedLocales_ be ? CanonicalizeLocaleList(_locales_). 115 | 1. Let _opt_ be a new Record. 116 | 1. Let _matcher_ be ? GetOption(_options_, *"localeMatcher"*, *"string"*, « *"lookup"*, *"best fit"* », *"best fit"*). 117 | 1. Set _opt_.[[localeMatcher]] to _matcher_. 118 | 1. Let _calendar_ be ? GetOption(_options_, *"calendar"*, *"string"*, *undefined*, *undefined*). 119 | 1. If _calendar_ is not *undefined*, then 120 | 1. If _calendar_ does not match the Unicode Locale Identifier `type` nonterminal, throw a *RangeError* exception. 121 | 1. Set _opt_.[[ca]] to _calendar_. 122 | 1. Let _numberingSystem_ be ? GetOption(_options_, *"numberingSystem"*, *"string"*, *undefined*, *undefined*). 123 | 1. If _numberingSystem_ is not *undefined*, then 124 | 1. If _numberingSystem_ does not match the Unicode Locale Identifier `type` nonterminal, throw a *RangeError* exception. 125 | 1. Set _opt_.[[nu]] to _numberingSystem_. 126 | 1. Let _hour12_ be ? GetOption(_options_, *"hour12"*, *"boolean"*, *undefined*, *undefined*). 127 | 1. Let _hourCycle_ be ? GetOption(_options_, *"hourCycle"*, *"string"*, « *"h11"*, *"h12"*, *"h23"*, *"h24"* », *undefined*). 128 | 1. If _hour12_ is not *undefined*, then 129 | 1. Set _hourCycle_ to *null*. 130 | 1. Set _opt_.[[hc]] to _hourCycle_. 131 | 1. Let _localeData_ be %DateTimeFormat%.[[LocaleData]]. 132 | 1. Let _r_ be ResolveLocale(%DateTimeFormat%.[[AvailableLocales]], _requestedLocales_, _opt_, %DateTimeFormat%.[[RelevantExtensionKeys]], _localeData_). 133 | 1. Set _dateTimeFormat_.[[Locale]] to _r_.[[locale]]. 134 | 1. Let _resolvedCalendar_ be _r_.[[ca]]. 135 | 1. Set _dateTimeFormat_.[[Calendar]] to _resolvedCalendar_. 136 | 1. Set _dateTimeFormat_.[[NumberingSystem]] to _r_.[[nu]]. 137 | 1. Let _dataLocale_ be _r_.[[dataLocale]]. 138 | 1. Let _dataLocaleData_ be _localeData_.[[<_dataLocale_>]]. 139 | 1. Let _hcDefault_ be _dataLocaleData_.[[hourCycle]]. 140 | 1. If _hour12_ is *true*, then 141 | 1. If _hcDefault_ is *"h11"* or *"h23"*, let _hc_ be *"h11"*. Otherwise, let _hc_ be *"h12"*. 142 | 1. Else if _hour12_ is *false*, then 143 | 1. If _hcDefault_ is *"h11"* or *"h23"*, let _hc_ be *"h23"*. Otherwise, let _hc_ be *"h24"*. 144 | 1. Else, 145 | 1. Assert: _hour12_ is *undefined*. 146 | 1. Let _hc_ be _r_.[[hc]]. 147 | 1. If _hc_ is *null*, set _hc_ to _hcDefault_. 148 | 1. Let _timeZone_ be ? Get(_options_, *"timeZone"*). 149 | 1. If _timeZone_ is *undefined*, then 150 | 1. If _toLocaleStringTimeZone_ is present, then 151 | 1. Set _timeZone_ to _toLocaleStringTimeZone_. 152 | 1. Else, 153 | 1. Set _timeZone_ to SystemTimeZoneIdentifier(). 154 | 1. Else, 155 | 1. If _toLocaleStringTimeZone_ is present, throw a *TypeError* exception. 156 | 1. Set _timeZone_ to ? ToString(_timeZone_). 157 | 1. Let _timeZoneIdentifierRecord_ be GetAvailableNamedTimeZoneIdentifier(_timeZone_). 158 | 1. If _timeZoneIdentifierRecord_ is ~empty~, then 159 | 1. Throw a *RangeError* exception. 160 | 1. Set _timeZone_ to _timeZoneIdentifierRecord_.[[PrimaryIdentifierIdentifier]]. 161 | 1. Set _dateTimeFormat_.[[TimeZone]] to _timeZone_. 162 | 1. Let _formatOptions_ be a new Record. 163 | 1. Set _formatOptions_.[[hourCycle]] to _hc_. 164 | 1. Let _hasExplicitFormatComponents_ be *false*. 165 | 1. For each row of , except the header row, in table order, do 166 | 1. Let _prop_ be the name given in the Property column of the row. 167 | 1. If _prop_ is *"fractionalSecondDigits"*, then 168 | 1. Let _value_ be ? GetNumberOption(_options_, *"fractionalSecondDigits"*, 1, 3, *undefined*). 169 | 1. Else, 170 | 1. Let _values_ be a List whose elements are the strings given in the Values column of the row. 171 | 1. Let _value_ be ? GetOption(_options_, _prop_, *"string"*, _values_, *undefined*). 172 | 1. Set _formatOptions_.[[<_prop_>]] to _value_. 173 | 1. If _value_ is not *undefined*, then 174 | 1. Set _hasExplicitFormatComponents_ to *true*. 175 | 1. Let _matcher_ be ? GetOption(_options_, *"formatMatcher"*, *"string"*, « *"basic"*, *"best fit"* », *"best fit"*). 176 | 1. Let _dateStyle_ be ? GetOption(_options_, *"dateStyle"*, *"string"*, « *"full"*, *"long"*, *"medium"*, *"short"* », *undefined*). 177 | 1. Set _dateTimeFormat_.[[DateStyle]] to _dateStyle_. 178 | 1. Let _timeStyle_ be ? GetOption(_options_, *"timeStyle"*, *"string"*, « *"full"*, *"long"*, *"medium"*, *"short"* », *undefined*). 179 | 1. Set _dateTimeFormat_.[[TimeStyle]] to _timeStyle_. 180 | 1. Let _expandedOptions_ be a copy of _formatOptions_. 181 | 1. Let _needDefaults_ be *true*. 182 | 1. For each element _field_ of « *"weekday"*, *"year"*, *"month"*, *"day"*, *"hour"*, *"minute"*, *"second"* » in List order, do 183 | 1. If _expandedOptions_.[[<_field_>]] is not *undefined*, then 184 | 1. Set _needDefaults_ to *false*. 185 | 1. If _needDefaults_ is *true*, then 186 | 1. For each element _field_ of « *"year"*, *"month"*, *"day"*, *"hour"*, *"minute"*, *"second"* » in List order, do 187 | 1. Set _expandedOptions_.[[<_field_>]] to *"numeric"*. 188 | 1. Let _bestFormat_ be GetDateTimeFormatPattern(_dateStyle_, _timeStyle_, _matcher_, _expandedOptions_, _dataLocaleData_, _hc_, _resolvedCalendar_, _hasExplicitFormatComponents_). 189 | 1. Set _dateTimeFormat_.[[Pattern]] to _bestFormat_.[[pattern]]. 190 | 1. Set _dateTimeFormat_.[[RangePatterns]] to _bestFormat_.[[rangePatterns]]. 191 | 1. For each row in , except the header row, in table order, do 192 | 1. Let _limitedOptions_ be a new Record. 193 | 1. Let _needDefaults_ be *true*. 194 | 1. Let _fields_ be the list of fields in the Supported fields column of the row. 195 | 1. For each field _field_ of _formatOptions_, do 196 | 1. If _field_ is in _fields_, then 197 | 1. Set _needDefaults_ to *false*. 198 | 1. Set _limitedOptions_.[[<_field_>]] to _formatOptions_.[[<_field_>]]. 199 | 1. If _needDefaults_ is *true*, then 200 | 1. Let _defaultFields_ be the list of fields in the Default fields column of the row. 201 | 1. If the Pattern column of the row is [[TemporalInstantPattern]], and _toLocaleStringTimeZone_ is present, append [[timeZoneName]] to _defaultFields_. 202 | 1. For each element _field_ of _defaultFields_, do 203 | 1. If _field_ is [[timeZoneName]], then 204 | 1. Let _defaultValue_ be *"short"*. 205 | 1. Else, 206 | 1. Let _defaultValue_ be *"numeric"*. 207 | 1. Set _limitedOptions_.[[<_field_>]] to _defaultValue_. 208 | 1. Let _bestFormat_ be GetDateTimeFormatPattern(_dateStyle_, _timeStyle_, _matcher_, _limitedOptions_, _dataLocaleData_, _hc_, _resolvedCalendar_, _hasExplicitFormatComponents_). 209 | 1. If _bestFormat_ does not have any fields that are in _fields_, then 210 | 1. Set _bestFormat_ to *null*. 211 | 1. Set _dateTimeFormat_'s internal slot whose name is the Pattern column of the row to _bestFormat_. 212 | 1. Return _dateTimeFormat_. 213 | 214 |
215 |
216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Handling Time Zone Canonicalization Changes 2 | 3 | Time zones in ECMAScript rely on IANA Time Zone Database ([TZDB](https://www.iana.org/time-zones)) identifiers like `America/Los_Angeles` or `Asia/Tokyo`. 4 | This proposal improves developer experience when the canonical identifier of a time zone is changed in TZDB, for example from `Europe/Kiev` to `Europe/Kyiv`. 5 | 6 | ## Status 7 | 8 | This proposal is currently [Stage 3](https://github.com/tc39/proposals/tree/main#stage-3) following the [TC39 process](https://tc39.es/process-document/). 9 | 10 | This proposal reached Stage 3 at the July 2023 TC39 meeting. 11 | Stage 3 reviewers were: Philip Chimento ([@ptomato](https://github.com/ptomato)), Daniel Minor ([@dminor](https://github.com/dminor)), and Jordan Harband ([@ljharb](https://github.com/ljharb)). 12 | 13 | It was agreed at that meeting that this proposal should be merged into the [Temporal proposal](https://github.com/tc39/proposal-temporal), which is also at Stage 3. 14 | This merger will happen via a normative PR into the Temporal repo, which this proposal's champions will file soon. 15 | After that PR lands, unresolved issues in this repo will be migrated to the [ECMA-402 repo](https://github.com/tc39/ecma402) and may be addressed via later normative PRs to ECMA-402. 16 | 17 | Here are [Slides](https://docs.google.com/presentation/d/1MVBKAB8U16ynSHmO6Mkt26hT5U-28OjyG9-L-GFdikE) for the July 2023 TC39 plenary meeting where this proposal advanced to Stage 3. 18 | 19 | Here are [Slides](https://docs.google.com/presentation/d/13vW8JxkbzyzGubT5ZkqUIxtpOQGNSUlguVwgcrbitog/) for the May 2023 TC39 plenary meeting where this proposal advanced to Stage 2. 20 | 21 | Here are [Slides](https://docs.google.com/presentation/d/13vW8JxkbzyzGubT5ZkqUIxtpOQGNSUlguVwgcrbitog/) from the March 2023 TC39 plenary meeting where this proposal advanced to Stage 1. 22 | 23 | This proposal's specification is "stacked" on top of the [Temporal proposal](https://github.com/tc39/proposal-temporal), because this proposal builds on Temporal features, especially the [`Temporal.TimeZone`](https://tc39.es/proposal-temporal/docs/timezone.html) built-in object. 24 | 25 | ## Specification 26 | 27 | The specification text for this proposal can be found at https://tc39.es/proposal-canonical-tz. 28 | This proposal's spec changes are narrow: about 15 lines of spec text changes (mostly one-line changes in existing abstract operations), plus one prose paragraph. 29 | 30 | ## Champions 31 | 32 | - Justin Grant ([@justingrant](https://github.com/justingrant)) 33 | - Richard Gibson ([@gibson042](https://github.com/gibson042/)) 34 | 35 | ## Tests 36 | 37 | Test262 tests covering this proposal's entire surface area are available at https://github.com/tc39/test262/pull/3837. 38 | 39 | ## Polyfill 40 | 41 | A non-production, for-testing-only polyfill is available. 42 | This proposal's polyfill is stacked on the Temporal proposal's non-production, test-only polyfill. See this proposal's [`polyfill README`](./polyfill/README.md) for more information. 43 | 44 | ## Contents 45 | 46 | - [Motivation](#Motivatione) 47 | - [Definitions](#Definitions) 48 | - [Proposed Solution](#Proposed-Solution---Summary) 49 | - [References](#References) 50 | 51 | ## Motivation 52 | 53 | ### Variation between implementations + spec doesn't match web reality 54 | 55 | ```javascript 56 | Temporal.TimeZone.from('Asia/Kolkata'); 57 | // => Asia/Kolkata (Firefox) 58 | // => Asia/Calcutta (Chrome, Safari, Node -- does not conform to ECMA-402) 59 | ``` 60 | 61 | ### Vague spec text: _"in the IANA Time Zone Database"_ 62 | 63 | ```javascript 64 | // TZDB has build options and the spec is silent on which to pick. 65 | // Default build options, while conforming, are bad for users. 66 | Temporal.TimeZone.from('Atlantic/Reykjavik'); 67 | // => Africa/Abidjan 68 | Temporal.TimeZone.from('Europe/Stockholm'); 69 | // => Europe/Berlin 70 | Temporal.TimeZone.from('Europe/Zagreb'); 71 | // => Europe/Belgrade 72 | ``` 73 | 74 | ### User Complaints 75 | 76 | - [CLDR-9892: 'Asia/Calcutta', 'Asia/Saigon' and 'Asia/Katmandu' are canonical even though they became obsolete in 1993](https://unicode-org.atlassian.net/browse/CLDR-9892) 77 | - [Chromium 580195: Asia/Calcutta Timezone Identifier should be replaced by Asia/Kolkata](https://bugs.chromium.org/p/chromium/issues/detail?id=580195) 78 | - [moment.tz.guess() in chrome is different from moment.tz.guess() in IE · Issue #453 · moment/moment-timezone ](https://github.com/moment/moment-timezone/issues/453) 79 | - [CLDR-5612: Timezones very outdated](https://unicode-org.atlassian.net/browse/CLDR-5612) 80 | - [CLDR-9718: Rename from Asia/Calcutta to Asia/Kolkata in Zone - Tzid mapping and windows mapping](https://unicode-org.atlassian.net/browse/CLDR-9718) 81 | - [Incorrect canonical time zone name for Asia/Kolkata · Issue #1076 · tc39/proposal-temporal](https://github.com/tc39/proposal-temporal/issues/1076) 82 | - [[tz] Kyiv not Kiev](https://mm.icann.org/pipermail/tz/2021-January/029695.html) (from [IANA TZDB mailing list](https://mm.icann.org/pipermail/tz/)). 83 | - [WebKit 218542: Incorrect timezone returned for Buenos Aires](https://bugs.webkit.org/show_bug.cgi?id=218542) 84 | - [Firefox 1796393: Javascript returns problematic timezone, breaking sites](https://bugzilla.mozilla.org/show_bug.cgi?id=1796393) 85 | - [Firefox 1825512: Europe/Kyiv is not a valid IANA timezone identifier](https://bugzilla.mozilla.org/show_bug.cgi?id=1825512) 86 | - It's easy to find dozens more. 87 | 88 | ### Can't depend on static data behaving the same over time 89 | 90 | ```shell 91 | > npm test 92 | # result = someFunctionToTest('Asia/Calcutta'); 93 | # assertEqual(result.timeZone.id, 'Asia/Calcutta'); 94 | ✅ 95 | > brew upgrade node 96 | > npm test 97 | ❌ 98 | Expected: 'Asia/Calcutta' 99 | Actual: 'Asia/Kolkata' 100 | ``` 101 | 102 | ### Comparing persisted identifiers with `===` is unreliable 103 | 104 | ```javascript 105 | userProfile.timeZoneId = Temporal.Now.timeZoneId(); 106 | userProfile.save(); 107 | // 1 year later (after canonicalization changed) 108 | userProfile.load(); 109 | if (userProfile.timeZoneId !== Temporal.Now.timeZoneId()) { 110 | alert('You moved!'); 111 | } 112 | ``` 113 | 114 | ### Temporal makes these problems more disruptive 115 | 116 | ```javascript 117 | // Today, canonicalization is invisible for most common use cases. 118 | // Intl.DateTimeFormat `format` localizes time zone names. 119 | // Only `resolvedOptions()` exposes the underlying IANA time zone. 120 | timestamp = '2023-03-10T12:00:00Z'; 121 | timeZone = 'Asia/Kolkata'; 122 | dtf = new Intl.DateTimeFormat('en', { timeZone, timeZoneName: 'long' }); 123 | dtf.format(new Date(timestamp)); 124 | // => '3/10/2023, India Standard Time' 125 | dtf.resolvedOptions().timeZone; 126 | // => 'Asia/Kolkata' (Firefox) 127 | // => 'Asia/Calcutta' (Chrome, Safari, Node) 128 | 129 | // In Temporal, canonical identifiers are *very* noticeable. 130 | zdt = Temporal.Instant.from(timestamp).toZonedDateTimeISO({ timeZone }); 131 | zdt.timeZoneId; 132 | // => 'Asia/Kolkata' (Firefox) 133 | // => 'Asia/Calcutta' (Chrome, Safari, Node) 134 | zdt.getTimeZone().id; 135 | // => 'Asia/Kolkata' (Firefox) 136 | // => 'Asia/Calcutta' (Chrome, Safari, Node) 137 | zdt.getTimeZone().toString(); 138 | // => 'Asia/Kolkata' (Firefox) 139 | // => 'Asia/Calcutta' (Chrome, Safari, Node) 140 | zdt.toString(); 141 | // => '2023-03-10T17:30:00+05:30[Asia/Kolkata]' (Firefox) 142 | // => '2023-03-10T17:30:00+05:30[Asia/Calcutta]' (Chrome, Safari, Node) 143 | ``` 144 | 145 | ## Definitions 146 | 147 | Additional details related to the terms below can be found at [Time Zone Identifiers](https://tc39.es/ecma262/#sec-time-zone-identifiers) in ECMA-262 and [Use of the IANA Time Zone Database](https://tc39.es/proposal-temporal/#sec-use-of-iana-time-zone-database) in the ECMA-402-amending part of the Temporal specification. 148 | 149 | - **Available Named Time Zone Identifier** - Time zone name in TZDB (and accepted in ECMAScript). 150 | Available named time zone identifiers are ASCII-only and are case-insensitive. 151 | There are currently 594 available named time zone identifiers (avg length 14.3 chars). 152 | Currently fewer than 5 identifiers are added per year. 153 | - **Primary Time Zone Identifier** - The preferred identifier for an available named time zone. 154 | - **Non-primary Time Zone Identifier** - Other identifiers for an available named time zone. 155 | An available named time zone has one primary identifier and 0 or more non-primary ones. 156 | - **Zone** - TZDB term for a collection of rules named by an identifier (see [How to Read the tz Database](https://data.iana.org/time-zones/tz-how-to.html) and [Theory and pragmatics](https://data.iana.org/time-zones/theory.html)). 157 | There are currently 461 Zones (avg length 14.9 chars). 158 | Except rare exceptions like `Etc/GMT`, all Zones in TZDB are primary time zone identifiers in ECMAScript. 159 | - **Link** - TZDB term for an Identifier that lacks its own Zone record but instead targets another Identifier and uses its rules. 160 | When a Zone is renamed, a Link from the old name to the new one is added to [`backward`](https://github.com/eggert/tz/blob/main/backward). 161 | Renaming to update the spelling of a city happens every few years. 162 | There are currently 133 Links (avg length 12.2 chars). 163 | Links in TZDB are generally non-primary time zone identifiers in ECMAScript, but variation in TZDB build options means that some Links in some builds of TZDB are primary time zone identifiers in ECMAScript. 164 | - **Case-normalization** - Match user-provided IDs to case in IANA TZDB. Example: `america/los_angeles` ⇨ `America/Los_Angeles` 165 | - **Canonicalization** - Return the IANA Zone, resolving Links if needed. Example: Europe/Kiev ⇨ Europe/Kyiv 166 | - **CLDR Canonicalization** - Zone & Link [data](https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-bcp47/bcp47/timezone.json) used by V8 & WebKit (via ICU libraries) 167 | - **IANA Canonicalization** - Zone & Link [data](https://github.com/tc39/proposal-temporal/issues/2509#issuecomment-1461418026) used by Firefox (in custom TZDB build) 168 | 169 | ## Proposed Solution - Summary 170 | 171 | Steps below are designed to be "severable"; if blocked due to implementation complexity and/or lack of consensus, we can still move forward on the others. 172 | 173 | ### Reduce variation between implementations, and between implementations and spec 174 | 175 | 1. **[DONE - Simplify abstract operations](#1-done---editorial-cleanup-of-identifier-related-terms-and-abstract-operations)** dealing with time zone identifiers. 176 | 2. **[DONE - Clarify spec](#2-done---clarify-spec-to-prevent-more-divergence)** to prevent more divergence 177 | 3. **[Help V8 and WebKit update 13 out-of-date canonicalizations](#3-fix-out-of-date-canonicalizations-in-v8webkit)** like `Asia/Calcutta`, `Europe/Kiev`, and `Asia/Saigon` before wide Temporal adoption makes this painful. (no spec text required) 178 | 4. [**Prescriptive spec text to reduce divergence between implementations.**](#4-prescriptive-spec-text-to-reduce-divergence-between-implementations) 179 | This step requires finding common ground between implementers as well as TG2 (the ECMA-402 team) about how canonicalization should work. 180 | 181 | ### Reduce impact of canonicalization changes 182 | 183 | 5. [**Avoid observable following of Links.**](#5-defer-link-traversing-canonicalization) 184 | If canonicalization changes don't affect existing code, then it's much less likely for future canonicalization changes to break the Web. 185 | Because canonicalization is implementation-defined, this change may (or may not; needs research) be safe to ship after Temporal Stage 4, but best to not wait too long. 186 | 187 | ```javascript 188 | Temporal.TimeZone.from('Asia/Calcutta'); 189 | // => Asia/Kolkata (current Temporal behavior on Firefox) 190 | // => Asia/Calcutta (proposed: don't follow Links when returning IDs to callers) 191 | ``` 192 | 193 | 6. [**Add `Temporal.TimeZone.prototype.equals`.**](#6-add-temporaltimezoneprototypeequals) 194 | Because (5) would stop canonicalizing IDs upon `TimeZone` object creation, it'd be helpful to have an ergonomic way to know if two `TimeZone` objects represent the same Zone. 195 | 196 | ```javascript 197 | // More ergonomic canonical-equality testing 198 | Temporal.TimeZone.from('Asia/Calcutta').equals('Asia/Kolkata'); 199 | // => true 200 | ``` 201 | 202 | ## Proposed Solution - Details 203 | 204 | ### 1. DONE - Editorial cleanup of identifier-related terms and Abstract Operations 205 | 206 | _Status: Editorial PR merged as [tc39/ecma262#3035](https://github.com/tc39/ecma262/pull/3035)_ 207 | 208 | A recent PR landed in ECMA-262 refactored the spec text for time zone identifiers. 209 | One change was a new [Time Zone Identifiers](https://tc39.es/ecma262/#sec-time-zone-identifiers) prose section that defines identifier-related terms and concepts. 210 | The other change was refactoring time-zone-identifier-related abstract operations, so that other time-zine-identifier-related functionality in ECMA-262, ECMA-402, and Temporal (including the normative changes in this proposal) can be built of these without resorting to new non-implementation-defined AOs: 211 | 212 | **`AvailableNamedTimeZoneIdentifiers()`** - Returns an implementation-defined List of Records, each composed of: 213 | 214 | - `[[Identifier]]`: identifier in the IANA TZDB 215 | - `[[PrimaryIdentifier]]`: identifier of transitive IANA Link target (following as many Links as necessary to reach a Zone), with the same special-casing of `"UTC"` as in ECMA-402's [CanonicalizeTimeZoneName](https://tc39.es/ecma402/#sec-canonicalizetimezonename). 216 | 217 | **`GetAvailableNamedTimeZoneIdentifier(_identifier_)`** - Filters the result of `AvailableNamedTimeZoneIdentifiers` to return the record where `[[Identifier]]` is an ASCII-case-insensitive match for `_identifier_`, or `~empty~` if there's no match. 218 | This AO: 219 | 220 | - Replaces `CanonicalizeTimeZoneName` in [Temporal](https://tc39.es/proposal-temporal/#sec-canonicalizetimezonename) and [ECMA-402](https://tc39.es/ecma402/#sec-canonicalizetimezonename) by using the `[[PrimaryIdentifier]]` of the result. 221 | - Replaces [`IsAvailableTimeZoneName`](https://tc39.es/proposal-temporal/#sec-isavailabletimezonename) in Temporal and [`IsValidTimeZoneName`](https://tc39.es/ecma402/#sec-isvalidtimezonename) in ECMA-402, by comparing the result to `~empty~`. 222 | - Fetches a case-normalized time zone identifier by using the `[[Identifier]]` of the result. 223 | This capability will be used as part of this proposal. 224 | 225 | The specification text of this proposal is stacked on top of these recently-merged editorial changes. 226 | 227 | ### 2. DONE - Clarify spec to prevent more divergence 228 | 229 | _Status: Editorial PR merged as [tc39/proposal-temporal#2573](https://github.com/tc39/proposal-temporal/pull/2573)._ 230 | 231 | The ECMA-402 section of the Temporal spec now includes a new [Use of the Time Zone Database](https://tc39.es/proposal-temporal/#sec-use-of-iana-time-zone-database) section that recommends best practices for building and handling updates to the IANA Time Zone Database. 232 | 233 | These recommendations are broad enough to encompass existing implementations in Firefox and V8/WebKit but not any broader than that. 234 | For example, all ECMAScript implementations seem to avoid use of the the default TZDB build options that perform over-eager canonicalization like `Atlantic/Reykjavik=>Africa/Abidjan`. 235 | We will recommend that all implementations follow this best practice. 236 | 237 | The goal of these changes was to provide a web-reality baseline that further cross-implementation alignment can build on. 238 | 239 | ### 3. Fix out-of-date canonicalizations in V8/WebKit 240 | 241 | _Status: At a CLDR meeting on 2023-03-29, CLDR [agreed](https://unicode-org.atlassian.net/browse/CLDR-14453?focusedCommentId=169191) to develop a design proposal to provide IANA current canonicalization info._ 242 | _Please follow https://unicode-org.atlassian.net/browse/CLDR-14453 for updates on progress._ 243 | 244 | The list below shows 13 Links that have been superseded in IANA and Firefox, but still canonicalize to the "old" identifier in CLDR (and hence ICU and therefore V8 and WebKit). 245 | The data below comes from the 2022g version of TZDB, via a simple [CodeSandbox app](https://4rylir.csb.app/) that tests browsers' canonicalization behavior. 246 | 247 | There is some urgency to fix these outdated Links because as noted [above](#Temporal-makes-these-problems-more-disruptive), changing canonicalization after Temporal is widely adopted will cause more churn and customer complaints. 248 | 249 | To fix these outdated Links, we'd partner with representatives from V8 and WebKit (and maybe ICU and CLDR too) to see if there's a quick, low-cost way for V8/WebKit to update canonicalization of these 13 Zones. 250 | 251 | Note that renaming of TZDB identifiers is very infrequent. 252 | From a review of the TZDB [NEWS](https://data.iana.org/time-zones/tzdb/NEWS) file, there was only one rename per year from 2020-2022. 253 | Before that, the last rename was Rangoon => Yangon in 2016. 254 | Ideally ICU would provide a timely solution to these outdated identifiers. 255 | But in the meantime, if implementations had to hand-code overrides in a special-case list, then it would not need to be updated often. 256 | 257 | ```javascript 258 | // [0] => canonical in V8/WebKit (from CLDR) 259 | // [1] => canonical in Firefox (from IANA) 260 | // [2] => non-canonical Link (if present) 261 | const outofDateLinks = [ 262 | ['Asia/Calcutta', 'Asia/Kolkata'], 263 | ['Europe/Kiev', 'Europe/Kyiv'], 264 | ['Asia/Saigon', 'Asia/Ho_Chi_Minh'], 265 | ['Asia/Rangoon', 'Asia/Yangon'], 266 | ['Asia/Ulaanbaatar', 'Asia/Ulan_Bator'], 267 | ['Asia/Katmandu', 'Asia/Kathmandu'], 268 | ['Africa/Asmera', 'Africa/Asmara'], 269 | ['America/Coral_Harbour', 'America/Atikokan'], 270 | ['Atlantic/Faeroe', 'Atlantic/Faroe'], 271 | ['America/Godthab', 'America/Nuuk'], 272 | ['Pacific/Truk', 'Pacific/Chuuk', 'Pacific/Yap'], 273 | ['Pacific/Enderbury', 'Pacific/Kanton'], 274 | ['Pacific/Ponape', 'Pacific/Pohnpei'] 275 | ]; 276 | ``` 277 | 278 | ### 4. Prescriptive spec text to reduce divergence between implementations 279 | 280 | _Status: At this point it seems unlikely that we'll get this cross-implementer agreement soon. So it's likely that we'll remove this step from the scope of this proposal, and follow up separately in parallel._ 281 | 282 | This step involves agreement between implementers and TG2 about how canonicalization should work. 283 | It may require agreeing on (or recommending) which external source of canonicalization (IANA or CLDR) ECMAScript should rely on, and (if IANA) which TZDB build options should be used. 284 | Making progress here requires input from specifiers and implementers who understand the tradeoffs involved. 285 | Note that one acceptable outcome may be to “agree to disagree” as long as we can agree on most parts. 286 | We don’t need perfect alignment to reduce ecosystem variance. 287 | 288 | There's useful info in [@anba](https://github.com/anba)'s comments [here](https://github.com/tc39/proposal-temporal/issues/2509#issuecomment-1461418026) that could be used as a starting point. 289 | 290 | It will likely be much easier to achieve consensus on this spec text after progress is made on (3) above, because those 13 outdated Links are the largest current difference in canonicalization behavior between Firefox and V8/WebKit. 291 | 292 | ### 5. Defer Link-traversing canonicalization 293 | 294 | _Status: [Spec text](https://tc39.es/proposal-canonical-tz), [polyfill](./polyfill/README.md), and [tests](https://github.com/tc39/test262/pull/3837) are complete._ 295 | 296 | This normative change would defer Link traversal to enable a Link identifier to be stored in internal slots of `ZonedDateTime`, `TimeZone`, and `Intl.DateTimeFormat`, so that it can be returned back to the user. 297 | 298 | The justification for this change is that canonicalization itself is problematic because it always makes at least some people unhappy: developers of existing code are annoyed when their code behaves differently, while other developers are annoyed if outdated identifiers are used. 299 | To sidestep both problems, this proposed change would make canonicalization mostly invisible to Temporal users, except one place: time zone identifiers returned from `Temporal.Now`. 300 | 301 | A tradeoff of this change is that comparing the string representation of `TimeZone` objects (or their `id` properties, or the time zone slot of `Temporal.ZonedDateTime`) would no longer be a reliable way to test for equality. 302 | Therefore, (6) below proposes a new `TimeZone.prototype.equals` API. 303 | 304 | This change requires the following normative edits: 305 | 306 | - a) Change `GetAvailableNamedTimeZoneIdentifier(id).[[PrimaryIdentifier]]` to `GetAvailableNamedTimeZoneIdentifier(id).[[Identifier]]` in places where user input identifiers are parsed and/or stored. 307 | - b) Call `GetAvailableNamedTimeZoneIdentifier(id).[[PrimaryIdentifier]]` before using identifiers for purposes that require canonicalization, such as the `TimeZoneEquals` abstract operation. 308 | 309 | These changes are described in the [spec text](https://tc39.es/proposal-canonical-tz), [polyfill](./polyfill/README.md), and [tests](https://github.com/tc39/test262/pull/3837) of this proposal. 310 | 311 | A few performance-related notes: 312 | 313 | - Storing user-input identifier strings is not necessary because identifiers are case-normalized by `GetCanonicalTimeZoneIdentifier` before storing. 314 | There are fewer than 600 identifiers, so built-in time zone identifiers could be stored as a 2-byte (or even 10-bit) indexes into a ~9KB array of ASCII strings. 315 | - This proposal WOULD NOT require storing both original and canonical ID indexes in each `TimeZone`, `ZonedDateTime`, and `Intl.DateTimeFormat` instance. 316 | Implementations could choose to do this for ease of implementation, but they can also save 1-2 bytes per instance by canonicalizing just-in-time via a 2.3KB map of each identifier's index to its corresponding Zone's identifier's index. 317 | 318 | ### 6. Add `Temporal.TimeZone.prototype.equals` 319 | 320 | _Status: [Spec text](https://tc39.es/proposal-canonical-tz), [polyfill](./polyfill/README.md), and [tests](https://github.com/tc39/test262/pull/3837) are complete._ 321 | 322 | The final step would expose Temporal's [`TimeZoneEquals`](https://tc39.es/proposal-temporal/#sec-temporal-timezoneequals) to ECMAScript code to enable developers to compare two time zones to see if they resolve to the same Zone. 323 | 324 | ```javascript 325 | // More ergonomic canonical-equality testing 326 | Temporal.TimeZone.from('Asia/Calcutta').equals('Asia/Kolkata'); 327 | // => true 328 | ``` 329 | 330 | This `equals` pattern matches how other Temporal types like `ZonedDateTime` and `PlainDate` offer equality comparisons. 331 | 332 | Behavior of `ZonedDateTime.prototype.equals` API would not change, because it (just like all other APIs other than those that return identifiers) would canonicalize before using time zone identifiers. 333 | 334 | A reason to include this new API in this proposal instead of waiting until later is that it'd prevent the pattern `tz1.id === tz2.id` from becoming endemic in ECMAScript code, because that pattern will be broken by this proposal. 335 | 336 | Without this API, testing for canonical equality is still possible, it's just less ergonomic: 337 | 338 | ```javascript 339 | const EPOCH = Temporal.Instant.fromNanoseconds(0n); 340 | function canonicalEquals(zone1, zone2) { 341 | const zdt1 = EPOCH.toZonedDateTimeISO(zone1); 342 | const zdt2 = EPOCH.toZonedDateTimeISO(zone2); 343 | return zdt1.equals(zdt2); 344 | } 345 | ``` 346 | 347 | Longer-term extensions (out of scope to this proposal) could be added to force canonicalization at creation time: 348 | 349 | ```javascript 350 | Temporal.TimeZone.from('Asia/Calcutta', { canonicalize: 'full' }); 351 | // => Asia/Kolkata 352 | // Opt-in canonicalization 353 | Temporal.TimeZone.canonicalize('Asia/Calcutta'); 354 | // => Asia/Kolkata 355 | ``` 356 | 357 | That said, exposing canonical identifiers has been a source of grief in every software platform. 358 | So adding APIs that make canonicalization more visible might invite more user complaints. 359 | This is another good reason to defer these kinds of APIs until a later proposal. :smile: 360 | 361 | ## TZDB size calculations 362 | 363 | Source: https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-bcp47/bcp47/timezone.json 364 | 365 | ```javascript 366 | // all identifiers 367 | ianaIdLists = Object.values(t.keyword.u.tz) 368 | .map((tz) => tz._alias) 369 | .filter((s) => s) 370 | .map((s) => s.split(/,* /g)); 371 | ids = ianaIdLists.flat(); 372 | zones = ianaIdLists.map((list) => list[0]); 373 | links = ianaIdLists.flatMap((list) => list.slice(1)); 374 | 375 | [ids, zones, links].map((arr) => arr.length); 376 | // => [594, 461, 133] 377 | 378 | avgLength = (arr) => arr.join('').length / arr.length; 379 | [ids, zones, links].map((arr) => avgLength(arr).toFixed(1)); 380 | // => [14.3, 14.9, 12.2] 381 | ``` 382 | 383 | CLDR Links and Zones above used in V8 and WebKit aren't exactly the same as IANA data used in Firefox. 384 | But CLDR is close enough that the numbers above should be within 20% of Firefox stats. 385 | 386 | ## References 387 | 388 | ### ICU4X 389 | 390 | Rust localization API (including Temporal-friendly [timezone API](https://github.com/unicode-org/icu4x/tree/main/components/timezone)) that's being implemented now. 391 | 392 | See https://github.com/unicode-org/icu4x/issues/2909 for canonicalization API discussion. 393 | 394 | ### IANA Time Zone Database ([TZDB](https://www.iana.org/time-zones)) 395 | 396 | Standard repository of time zone data, including Link and Zone identifiers and data required to calculate the UTC offset of moments in time for any time zone. 397 | 398 | Maintained via PRs to the [eggert/tz](https://github.com/eggert/tz) repo, with discussion on the [TZDB mailing list](https://mm.icann.org/pipermail/tz/). 399 | 400 | The data in the TZDB repo is not intended to be used raw. 401 | Instead, the repo's MAKEFILE offers various build options which will generate data files for use by applications. 402 | Changes in build options can yield very different output, including large differences in canonicalization behavior. 403 | 404 | One of the goals of this proposal is to define which of these build options should be used by ECMAScript implementations. 405 | 406 | ### [global-tz](https://github.com/JodaOrg/global-tz) repo 407 | 408 | Provides pre-built TZDB data files using build options that are more aligned with the needs of Java (and also ECMAScript) than the default TZDB build options. 409 | 410 | The files in global-tz are claimed (by the [TZDB News](https://github.com/eggert/tz/blob/27148539e699d9abe50df84371a077fdf2bc13de/NEWS#L427-L430) file) to be the same as the results of building TZDB with `make PACKRATDATA=backzone PACKRATLIST=zone.tab`. 411 | This build configuration backs out undesirable (from ECMAScript's point of view) merging of unrelated time zones like `Atlantic/Reykjavik` and `Africa/Abidjan` that may diverge in the future. 412 | 413 | Also, this build configuration is similar to the Zones and Links used by Firefox. 414 | 415 | This repo is maintained by the champion of [JSR-310](https://jcp.org/en/jsr/detail?id=310), the current Java date/time API and maintainer of the [Joda](https://github.com/JodaOrg/joda-time) date/time API library which is used by older Java implementations and which JSR-310 was based on. 416 | 417 | Note that the string serialization format of `Temporal.ZonedDateTime`, including use of IANA time zone identifiers, was designed to be interoperable with [`java.time.ZonedDateTime`](https://docs.oracle.com/javase/8/docs/api/java/time/ZonedDateTime.html). 418 | In addition, ECMAScript's time zone use cases are similar to Java's. 419 | So this may be a useful standard TZDB build configuration to consider recommending for in ECMAScript. 420 | --------------------------------------------------------------------------------