Compare commits

..

8 Commits

Author SHA1 Message Date
SkyZeroZx c60c41e29a test(core): reduce fakeAsync usage in animation tests
Use async/await in animation acceptance tests and share the animation frame helper. Remove the obsolete fakeAsync frame utility and its Bazel dependency.
2026-09-13 14:20:24 -07:00
Kam fc2f6fd67d docs: stop the disabled aria toolbar radio group from changing selection
The disabled toolbar example marks its alignment radio group `disabled`.
Toolbars are soft-disabled by default, so the widgets only receive
`aria-disabled` and stay clickable, which the guide describes as focusable
but unavailable.

Since #70516 moved selection into the app, each radio also carries a
`(click)="alignment.set(...)"` handler that never checks the disabled state.
Clicking a disabled radio therefore changes the selection. Before that change
the radios had no app listener and the library ignored clicks on disabled
items.

Drop the three click handlers from the permanently disabled group in the
basic, material and retro variants, the same way the disabled redo button in
the same template already has none.
2026-09-11 16:00:02 -07:00
aparziale f2cf65a898 fix(vscode-extension): handle template literals when detecting supported decorator fields
The client decides whether a position in a TypeScript file belongs to a supported `@Component` field (`template`, `styles`, ...) with a lightweight `ts.Scanner` loop before forwarding requests to the language server and the HTML/CSS providers. `ts.Scanner#scan` doesn't handle template substitutions on its own: the `}` closing a `${...}` substitution is reported as a plain `CloseBraceToken` and the rest of the template literal is scanned as the start of a new one, swallowing everything up to the next backtick. As a result, once a template literal with substitutions appears in a file, every position after it is considered to be inside a template string, and TypeScript completions get polluted with HTML completions.

The scanner is now driven the same way the TypeScript parser does it, re-scanning the brace that closes a substitution as a `TemplateMiddle`/`TemplateTail` token.

In addition, the property assignment context previously only ended at a terminator found at the top level of the file, so it leaked from `template:` to every string literal that followed it inside the same decorator and class body. The context now ends at the first terminator found at the nesting depth where it was entered. Two kinds of strings were only recognized thanks to that leak, and only when declared after `template`: inline `styles` and the arguments of `@HostBinding`/`@HostListener` decorators, both of which the language service supports. They are now recognized explicitly, regardless of where they appear.

Fixes #65494
2026-09-11 15:19:01 -07:00
Shuaib Hasan Akib 467b37b4b4 docs(forms): document what required() considers empty
The `required()` validator treats `null`, `undefined`, `''`, `false` and `NaN` as empty, but the API
docs never defined "empty" at all and the validation guide listed only `null` and `''`.
2026-09-11 15:16:17 -07:00
Andrew Scott 2dcdf9aae6 fix(router): mark router_resource module-level symbols as side-effect free
Top-level Symbol() calls in router_resource.ts lacked /* @__PURE__ */
annotations, preventing bundlers like esbuild and Rollup from tree-shaking
the module and its dependencies when provideRouter() is used without
withRouterResources().

Fixes #70696
2026-09-11 14:40:05 -07:00
Kam c097c0144c docs: import bootstrapApplication from platform-browser in the CSP example
The `CSP_NONCE` example in the security guide imports `bootstrapApplication`
from `@angular/core`, which does not export it, so copying the snippet fails
with "has no exported member named 'bootstrapApplication'". It is exported from
`@angular/platform-browser`, which is where every other example in the docs
imports it from.

Keep `CSP_NONCE` on `@angular/core` and import `bootstrapApplication` from
`@angular/platform-browser`.
2026-09-11 13:12:29 -07:00
Joey Perrott 1b7dcc854a ci: stop passing preview gating inputs to adev preview build
The Googler check moved out of `pack-and-upload-artifact` and into the
pull request labeling action, which runs in a privileged
`pull_request_target` context where secrets are available. The build job
runs on `pull_request` and never receives secrets, so it no longer needs
`angular-robot-key` or `triggering-label`, and the `bypassed_for_forks`
placeholder can go away. Bumps the dev-infra pins to pick up that change.
2026-09-11 13:11:59 -07:00
Andrew Scott 2c6c67bee6 fix(router): maintain frozen state on rollback until resource loading completes
Previously, rollback recovery waited on hasValueOrResolved(), which checked whether the resource already had a value. This caused resources with values (e.g. defaultValue, existing values from prior navigations, or streamed emissions) to unfreeze prematurely while still in a loading state during rollback.

This change updates the rollback recovery check in the transactional snapshot effect to be solely determined by !source.isLoading().
2026-09-11 13:06:43 -07:00
29 changed files with 808 additions and 482 deletions
+4 -6
View File
@@ -21,21 +21,19 @@ jobs:
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'adev: preview'))
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Build adev
# `snapshot-build` config is used to stamp the exact version with sha in the footer.
run: pnpm bazel build //adev:build.production --config=snapshot-build
- uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@183403ae13b785698eaf13c819dda55b9fed430b # main
- uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
workflow-artifact-name: 'adev-preview'
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY || 'bypassed_for_forks' }}
triggering-label: 'adev: preview'
pull-number: '${{github.event.pull_request.number}}'
artifact-build-revision: '${{github.event.pull_request.head.sha}}'
deploy-directory: './dist/bin/adev/dist/browser'
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
npx -y firebase-tools@15.15.0 target:clear --config adev/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting angular-docs
npx -y firebase-tools@15.15.0 target:apply --config adev/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting angular-docs ${{env.PREVIEW_SITE}}
- uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@183403ae13b785698eaf13c819dda55b9fed430b # main
- uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
github-token: '${{secrets.GITHUB_TOKEN}}'
workflow-artifact-name: 'adev-preview'
@@ -18,6 +18,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: angular/dev-infra/github-actions/branch-manager@183403ae13b785698eaf13c819dda55b9fed430b # main
- uses: angular/dev-infra/github-actions/branch-manager@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
+2 -2
View File
@@ -50,9 +50,9 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
- uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
bazelrc: ./.bazelrc.user
+25 -25
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Check code lint
@@ -41,13 +41,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
disable-package-manager-cache: true
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Install node modules
@@ -69,11 +69,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel Remote Caching
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Install node modules
@@ -85,11 +85,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel Remote Caching
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Install node modules
@@ -102,11 +102,11 @@ jobs:
labels: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Install node modules
@@ -121,11 +121,11 @@ jobs:
labels: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Install node modules
@@ -138,11 +138,11 @@ jobs:
labels: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- run: echo "https://${{secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN}}:@github.com" > ${HOME}/.git_credentials
@@ -154,11 +154,11 @@ jobs:
labels: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }}
- name: Install node modules
@@ -198,11 +198,11 @@ jobs:
runs-on: ubuntu-latest-8core
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Build adev
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- uses: angular/dev-infra/github-actions/labeling/pull-request@183403ae13b785698eaf13c819dda55b9fed430b # main
- uses: angular/dev-infra/github-actions/labeling/pull-request@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
labels: '{"requires: TGP": ["packages/core/primitives/**/{*,.*}"]}'
@@ -24,14 +24,14 @@ jobs:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- uses: angular/dev-infra/github-actions/post-approval-changes@183403ae13b785698eaf13c819dda55b9fed430b # main
- uses: angular/dev-infra/github-actions/post-approval-changes@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
issue_labels:
if: github.event_name == 'issues'
runs-on: ubuntu-latest
steps:
- uses: angular/dev-infra/github-actions/labeling/issue@183403ae13b785698eaf13c819dda55b9fed430b # main
- uses: angular/dev-infra/github-actions/labeling/issue@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
google-generative-ai-key: ${{ secrets.GOOGLE_GENERATIVE_AI_KEY }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: angular/dev-infra/github-actions/google-internal-tests@183403ae13b785698eaf13c819dda55b9fed430b # main
- uses: angular/dev-infra/github-actions/google-internal-tests@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
run-tests-guide-url: http://go/angular-g3sync-start
github-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
status:
runs-on: ubuntu-latest
steps:
- uses: angular/dev-infra/github-actions/unified-status-check@183403ae13b785698eaf13c819dda55b9fed430b # main
- uses: angular/dev-infra/github-actions/unified-status-check@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
workflows: ${{ steps.workflows.outputs.workflows }}
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- id: workflows
@@ -38,9 +38,9 @@ jobs:
workflow: ${{ fromJSON(needs.list.outputs.workflows) }}
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
# We utilize the google-github-actions/auth action to allow us to get an active credential using workflow
+20 -20
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Check code lint
@@ -39,7 +39,7 @@ jobs:
- name: Check code format
run: pnpm ng-dev format changed --check ${{ github.event.pull_request.base.sha }}
- name: Check Package Licenses
uses: angular/dev-infra/github-actions/linting/licenses@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/linting/licenses@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
allow-dependencies-licenses: 'pkg:npm/google-protobuf@'
@@ -47,13 +47,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
with:
disable-package-manager-cache: true
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Run unit tests
@@ -73,11 +73,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel Remote Caching
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Run CI tests for framework
@@ -97,11 +97,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel Remote Caching
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Run integration CI tests for framework
@@ -112,11 +112,11 @@ jobs:
labels: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Run tests
@@ -129,11 +129,11 @@ jobs:
labels: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- name: Run tests
@@ -144,11 +144,11 @@ jobs:
labels: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/setup@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/github-actions/bazel/configure-remote@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
- name: Install node modules
run: pnpm install --frozen-lockfile
- run: |
+1 -1
View File
@@ -18,7 +18,7 @@ permissions:
jobs:
release:
uses: angular/dev-infra/.github/workflows/reusable-release.yml@183403ae13b785698eaf13c819dda55b9fed430b # main
uses: angular/dev-infra/.github/workflows/reusable-release.yml@4a09f9588769980eacb41fcc76e2c76d048eac42 # main
secrets:
wombot-token: ${{ secrets.WOMBOT_TOKEN }}
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
+1 -1
View File
@@ -25,7 +25,7 @@ git_override(
bazel_dep(name = "devinfra")
git_override(
module_name = "devinfra",
commit = "183403ae13b785698eaf13c819dda55b9fed430b",
commit = "4a09f9588769980eacb41fcc76e2c76d048eac42",
remote = "https://github.com/angular/dev-infra.git",
)
@@ -84,7 +84,6 @@
aria-label="align left"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'left'"
(click)="alignment.set('left')"
translate="no"
>
format_align_left
@@ -98,7 +97,6 @@
aria-label="align center"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'center'"
(click)="alignment.set('center')"
translate="no"
>
format_align_center
@@ -112,7 +110,6 @@
aria-label="align right"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'right'"
(click)="alignment.set('right')"
translate="no"
>
format_align_right
@@ -84,7 +84,6 @@
aria-label="align left"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'left'"
(click)="alignment.set('left')"
translate="no"
>
format_align_left
@@ -98,7 +97,6 @@
aria-label="align center"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'center'"
(click)="alignment.set('center')"
translate="no"
>
format_align_center
@@ -112,7 +110,6 @@
aria-label="align right"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'right'"
(click)="alignment.set('right')"
translate="no"
>
format_align_right
@@ -84,7 +84,6 @@
aria-label="align left"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'left'"
(click)="alignment.set('left')"
translate="no"
>
format_align_left
@@ -98,7 +97,6 @@
aria-label="align center"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'center'"
(click)="alignment.set('center')"
translate="no"
>
format_align_center
@@ -112,7 +110,6 @@
aria-label="align right"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'right'"
(click)="alignment.set('right')"
translate="no"
>
format_align_right
@@ -110,12 +110,22 @@ export class RegistrationComponent {
}
```
A field is considered "empty" when:
A field is considered "empty" when its value is one of the following, and non-empty for every other
value — including `0` and the empty array `[]`:
| Condition | Example |
| ------------------------ | ------- |
| Value is `null` | `null`, |
| Value is an empty string | `''` |
| Condition | Example |
| ------------------------ | ----------- |
| Value is `null` | `null` |
| Value is `undefined` | `undefined` |
| Value is an empty string | `''` |
| Value is `false` | `false` |
| Value is `NaN` | `NaN` |
The last two are worth calling out:
- `false` is empty to follow the native semantics of `required` on `<input type="checkbox">`, where
an unchecked box fails validation.
- `NaN` is empty because it is usually the result of a parsing error, and is not a valid number.
For conditional requirements, use the `when` option:
@@ -130,7 +140,7 @@ registrationForm = form(this.registrationModel, (schemaPath) => {
The validation rule only runs when the `when` function returns `true`.
Note: `required` treats an empty array as present (valid), so use [`minLength()`](#minlength-and-maxlength) to enforce a minimum number of array items; it treats `false` as missing (invalid), matching `<input type="checkbox" required>`.
Note: `required` treats an empty array as present (valid), so use [`minLength()`](#minlength-and-maxlength) to enforce a minimum number of array items.
### email()
@@ -505,9 +515,7 @@ interface User {
lastName: string;
}
@Component({
/* ... */
})
@Component({/* ... */})
export class UserFormComponent {
readonly userModel = model<User>({
firstName: '',
@@ -750,9 +758,7 @@ import {Component, computed, signal} from '@angular/core';
import {form, FormField, validateStandardSchema} from '@angular/forms/signals';
import z from 'zod';
@Component({
/* ... */
})
@Component({/* ... */})
export class DynamicSchema {
model = signal({document: '', type: 'dni'});
+2 -1
View File
@@ -155,7 +155,8 @@ You can set the nonce for Angular in one of the following ways:
1. Provide the nonce using the `CSP_NONCE` injection token. Use this approach if you have access to the nonce at runtime and you want to be able to cache the `index.html`.
```ts
import {bootstrapApplication, CSP_NONCE} from '@angular/core';
import {CSP_NONCE} from '@angular/core';
import {bootstrapApplication} from '@angular/platform-browser';
import {AppComponent} from './app/app.component';
bootstrapApplication(AppComponent, {
@@ -27,7 +27,6 @@ ts_project(
"//packages/core/primitives/signals",
"//packages/core/src/di/interface",
"//packages/core/src/util",
"//packages/core/test/animation_utils",
"//packages/core/test/render3:matchers",
"//packages/core/testing",
"//packages/localize",
File diff suppressed because it is too large Load Diff
@@ -1,14 +0,0 @@
load("//tools:defaults.bzl", "ts_project")
package(default_visibility = ["//visibility:public"])
ts_project(
name = "animation_utils",
testonly = True,
srcs = [
"tick_animation_frames.ts",
],
deps = [
"//packages/core/testing",
],
)
@@ -1,14 +0,0 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {tick} from '@angular/core/testing';
/** Ticks the specified amount of `requestAnimationFrame`-s. */
export function tickAnimationFrames(amount: number) {
tick(16.6 * amount); // Angular turns rAF calls into 16.6ms timeouts in tests.
}
@@ -17,6 +17,14 @@ import {requiredError} from './validation_errors';
* This function can only be called on any type of path.
* In addition to binding a validator, this function adds `REQUIRED` property to the field.
*
* A value is considered empty when it is `null`, `undefined`, the empty string `''`, `false`, or
* `NaN`. Every other value is considered non-empty, including `0` and the empty array `[]` — use
* [`minLength()`](api/forms/signals/minLength) to require a minimum number of items in an array.
*
* `false` is empty to follow the native semantics of `required` on `<input type="checkbox">`, where
* an unchecked box fails validation. `NaN` is empty because it is usually the result of a parsing
* error, and is not a valid number.
*
* @param path Path of the field to validate
* @param config Optional, allows providing any of the following options:
* - `message`: A user-facing message for the error.
@@ -12,6 +12,58 @@ import {form, required} from '../../../../public_api';
import {requiredError} from '../../../../src/api/rules/validation/validation_errors';
describe('required validator', () => {
// Documented on `required()` and in guide/forms/signals/validation#required. `false` follows the
// native semantics of `required` on `<input type="checkbox">`; `NaN` is not a valid number.
describe('emptiness', () => {
it('treats null, empty string, false and NaN as empty', () => {
const model = signal<{
nullable: string | null;
text: string;
checkbox: boolean;
num: number;
}>({nullable: null, text: '', checkbox: false, num: Number.NaN});
const f = form(
model,
(p) => {
required(p.nullable);
required(p.text);
required(p.checkbox);
required(p.num);
},
{injector: TestBed.inject(Injector)},
);
expect(f.nullable().errors()).toEqual([requiredError({fieldTree: f.nullable})]);
expect(f.text().errors()).toEqual([requiredError({fieldTree: f.text})]);
expect(f.checkbox().errors()).toEqual([requiredError({fieldTree: f.checkbox})]);
expect(f.num().errors()).toEqual([requiredError({fieldTree: f.num})]);
});
it('treats 0, an empty array and other filled values as non-empty', () => {
const model = signal<{
zero: number;
list: string[];
checkbox: boolean;
text: string;
}>({zero: 0, list: [], checkbox: true, text: 'a'});
const f = form(
model,
(p) => {
required(p.zero);
required(p.list);
required(p.checkbox);
required(p.text);
},
{injector: TestBed.inject(Injector)},
);
expect(f.zero().errors()).toEqual([]);
expect(f.list().errors()).toEqual([]);
expect(f.checkbox().errors()).toEqual([]);
expect(f.text().errors()).toEqual([]);
});
});
it('returns required Error when the value is not present', () => {
const cat = signal({name: ''});
const f = form(
@@ -9,6 +9,11 @@
import {ɵgetDOM as getDOM} from '@angular/common';
import {NgZone} from '@angular/core';
/** Waits for callbacks queued for the next animation frame to run. */
export function nextAnimationFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
export function dispatchEvent(element: any, eventType: any): Event {
const evt: Event = getDOM().getDefaultDocument().createEvent('Event');
evt.initEvent(eventType, true, true);
+3 -11
View File
@@ -33,21 +33,13 @@ import {
NavigationCancellationCode,
} from './events';
export const BLOCKING_SYMBOL: unique symbol = Symbol(
export const BLOCKING_SYMBOL: unique symbol = /* @__PURE__ */ Symbol(
typeof ngDevMode === 'undefined' || ngDevMode ? '__isBlocking' : '',
);
export const SOURCE_RESOURCE_SYMBOL: unique symbol = Symbol(
export const SOURCE_RESOURCE_SYMBOL: unique symbol = /* @__PURE__ */ Symbol(
typeof ngDevMode === 'undefined' || ngDevMode ? '__sourceResource' : '',
);
/**
* Checks if a resource has a value or has transitioned to a resolved/non-loading status.
*/
export function hasValueOrResolved(res: Resource<unknown>): boolean {
const status = res.status();
return res.hasValue() || (status !== 'loading' && status !== 'reloading');
}
/**
* @internal
*/
@@ -169,7 +161,7 @@ function createTransactionalSnapshot<T>(
effect(
() => {
if (isRollbackRecoveryPending() && hasValueOrResolved(source)) {
if (isRollbackRecoveryPending() && !source.isLoading()) {
isRollbackRecoveryPending.set(false);
frozenSnapshot.set(null);
}
@@ -466,24 +466,30 @@ describe('routerResource behavior tests', () => {
expect(wrapped.value()).toBe('updated-2');
});
it('should complete rollback recovery when a resource has a value even while remaining in loading state', async () => {
it('should maintain frozen state during rollback recovery until loading completes', async () => {
const valueSignal = signal<string | undefined>('initial');
const statusSignal = signal<ResourceStatus>('resolved');
const isLoadingSignal = signal<boolean>(false);
const hasValueSignal = signal<boolean>(true);
const customResource: Resource<string> = {
value: valueSignal as Signal<string>,
status: signal<ResourceStatus>('loading').asReadonly(),
isLoading: signal(true).asReadonly(),
status: statusSignal.asReadonly(),
isLoading: isLoadingSignal.asReadonly(),
hasValue: (() => hasValueSignal()) as any,
error: signal<Error | undefined>(undefined).asReadonly(),
snapshot: computed(() => ({
status: 'loading' as const,
value: valueSignal()!,
})),
snapshot: computed(
() =>
({
status: statusSignal(),
value: valueSignal()!,
}) as any,
),
};
const wrapped = TestBed.runInInjectionContext(() => routerResource(customResource));
expect(wrapped.value()).toBe('initial');
expect(wrapped.isLoading()).toBe(false);
// Start navigation to route2 with a failing guard to trigger rollback
guardPromise2 = Promise.reject(new Error('Navigation failed'));
@@ -492,21 +498,33 @@ describe('routerResource behavior tests', () => {
} catch {}
// Reset value and set hasValue to false to simulate recovery fetch starting
statusSignal.set('loading');
isLoadingSignal.set(true);
valueSignal.set(undefined);
hasValueSignal.set(false);
await timeout();
// Wrapped snapshot should be frozen at 'initial' during recovery loading
expect(wrapped.value()).toBe('initial');
expect(wrapped.isLoading()).toBe(false);
// Resource receives value while isLoading() remains true and status is 'loading'
valueSignal.set('recovered-stream-1');
hasValueSignal.set(true);
await timeout();
// Rollback recovery remains frozen because isLoading is still true
expect(wrapped.value()).toBe('initial');
expect(wrapped.isLoading()).toBe(false);
// Loading completes
statusSignal.set('resolved');
isLoadingSignal.set(false);
await harness.fixture.whenStable();
// Rollback recovery unfreezes because hasValue is true despite isLoading being true
// Rollback recovery unfreezes because loading is complete
expect(wrapped.value()).toBe('recovered-stream-1');
expect(wrapped.isLoading()).toBe(true);
expect(wrapped.isLoading()).toBe(false);
});
});
});
@@ -619,6 +619,62 @@ describe('Router resources integration', () => {
expect(resourceRef.isLoading()).toBe(false);
});
it('should maintain frozen state on rollback when navigation fails until rollback reload completes', async () => {
let loaderDeferred = promiseWithResolvers<string>();
const {harness, router} = await setupRouter([
{
path: 'test/:id',
component: TargetCmp,
resources: (ctx) => ({
data: resource({
defaultValue: 'default-value',
params: () => ctx.params(),
loader: async ({params}) => {
if (params['id'] === '2') {
throw new Error('Failed navigation');
}
return loaderDeferred.promise;
},
}),
}),
},
]);
// Initial navigation to /test/1
const nav1 = harness.navigateByUrl('/test/1');
await timeout();
loaderDeferred.resolve('loaded-1');
await nav1;
await harness.fixture.whenStable();
const resourceRef = (router.routerState.root.firstChild as ActivatedRouteInternal)
?.resources?.['data'] as any;
expect(resourceRef.value()).toBe('loaded-1');
expect(resourceRef.isLoading()).toBe(false);
// Prepare a new deferred for the reload of id '1' when /test/2 fails and rolls back to /test/1
loaderDeferred = promiseWithResolvers<string>();
const nav2 = harness.navigateByUrl('/test/2').catch(() => {});
await nav2;
await timeout();
// The rollback reload for /test/1 is pending (loaderDeferred is not resolved yet).
// The resource should remain frozen: value is 'loaded-1', isLoading is false, status is 'resolved'
expect(resourceRef.isLoading()).toBe(false);
expect(resourceRef.value()).toBe('loaded-1');
expect(resourceRef.status()).toBe('resolved');
// Now resolve the rollback reload
loaderDeferred.resolve('reloaded-1');
await harness.fixture.whenStable();
// After settling, resource is unfrozen and has the new value
expect(resourceRef.isLoading()).toBe(false);
expect(resourceRef.value()).toBe('reloaded-1');
});
it('should abort previous request via AbortSignal when a new navigation comes in', async () => {
const deferred = promiseWithResolvers<{name: string}>();
let aborted = false;
@@ -11,11 +11,14 @@ import * as vscode from 'vscode';
const ANGULAR_PROPERTY_ASSIGNMENTS = new Set([
'template',
'templateUrl',
'styles',
'styleUrls',
'styleUrl',
'host',
]);
const ANGULAR_HOST_BINDING_DECORATORS = new Set(['HostBinding', 'HostListener']);
/**
* Determines if the position is inside a decorator
* property that supports language service features.
@@ -27,10 +30,11 @@ export function isNotTypescriptOrSupportedDecoratorField(
if (document.languageId !== 'typescript') {
return true;
}
return isPropertyAssignmentToStringOrStringInArray(
return isInsideAngularStringLiteral(
document.getText(),
document.offsetAt(position),
ANGULAR_PROPERTY_ASSIGNMENTS,
ANGULAR_HOST_BINDING_DECORATORS,
);
}
@@ -65,27 +69,21 @@ export function isInsideStringLiteral(
return true;
}
const offset = document.offsetAt(position);
const scanner = ts.createScanner(ts.ScriptTarget.ESNext, true /* skipTrivia */);
scanner.setText(document.getText());
const {scanner, scan} = createTemplateAwareScanner(document.getText());
let token: ts.SyntaxKind = scanner.scan();
let token: ts.SyntaxKind = scan();
while (token !== ts.SyntaxKind.EndOfFileToken && scanner.getStartPos() < offset) {
const isStringToken =
token === ts.SyntaxKind.StringLiteral ||
token === ts.SyntaxKind.NoSubstitutionTemplateLiteral;
const isCursorInToken =
scanner.getStartPos() <= offset &&
scanner.getStartPos() + scanner.getTokenText().length >= offset;
if (isCursorInToken && isStringToken) {
if (isStringToken(token) && isCursorInToken(scanner, offset)) {
return true;
}
token = scanner.scan();
token = scan();
}
return false;
}
/**
* Basic scanner to determine if we're inside a string of a property with one of the given names.
* Basic scanner to determine if we're inside a string of a property with one of the given names,
* or inside a string argument of a decorator with one of the given names.
*
* This scanner is not currently robust or perfect but provides us with an accurate answer _most_ of
* the time.
@@ -95,24 +93,32 @@ export function isInsideStringLiteral(
* `@Component` or `{styleUrls: [someFunction('stringL¦iteral')]}`, the @angular/language-service
* will always give us the correct answer. This helper gives us a quick win for optimizing the
* number of requests we send to the server.
*
* TODO(atscott): tagged templates don't work: #1872 /
* https://github.com/Microsoft/TypeScript/issues/20055
*/
function isPropertyAssignmentToStringOrStringInArray(
function isInsideAngularStringLiteral(
documentText: string,
offset: number,
propertyAssignmentNames: Set<string>,
decoratorNames: Set<string>,
): boolean {
const scanner = ts.createScanner(ts.ScriptTarget.ESNext, true /* skipTrivia */);
scanner.setText(documentText);
const {scanner, scan} = createTemplateAwareScanner(documentText);
let token: ts.SyntaxKind = scanner.scan();
let token: ts.SyntaxKind = scan();
let lastToken: ts.SyntaxKind | undefined;
let lastTokenText: string | undefined;
let secondLastToken: ts.SyntaxKind | undefined;
let unclosedBraces = 0;
let unclosedBrackets = 0;
let unclosedParens = 0;
let propertyAssignmentContext = false;
// Nesting depth at which the current property assignment context was entered. The context ends
// at the first terminator found at that same depth, i.e. the one that ends the assignment's
// initializer rather than one nested inside of it.
let contextBraces = 0;
let contextBrackets = 0;
let decoratorContext = false;
// Parenthesis depth at which the current decorator context was entered. The context ends at the
// closing parenthesis of the decorator call.
let contextParens = 0;
while (token !== ts.SyntaxKind.EndOfFileToken && scanner.getStartPos() < offset) {
if (
lastToken === ts.SyntaxKind.Identifier &&
@@ -121,36 +127,64 @@ function isPropertyAssignmentToStringOrStringInArray(
propertyAssignmentNames.has(lastTokenText)
) {
propertyAssignmentContext = true;
token = scanner.scan();
contextBraces = unclosedBraces;
contextBrackets = unclosedBrackets;
token = scan();
continue;
}
if (unclosedBraces === 0 && unclosedBrackets === 0 && isPropertyAssignmentTerminator(token)) {
if (
propertyAssignmentContext &&
unclosedBraces === contextBraces &&
unclosedBrackets === contextBrackets &&
isPropertyAssignmentTerminator(token)
) {
propertyAssignmentContext = false;
}
if (
secondLastToken === ts.SyntaxKind.AtToken &&
lastToken === ts.SyntaxKind.Identifier &&
lastTokenText !== undefined &&
token === ts.SyntaxKind.OpenParenToken &&
decoratorNames.has(lastTokenText)
) {
decoratorContext = true;
contextParens = unclosedParens;
}
if (
decoratorContext &&
token === ts.SyntaxKind.CloseParenToken &&
unclosedParens === contextParens + 1
) {
decoratorContext = false;
}
if (token === ts.SyntaxKind.OpenBracketToken) {
unclosedBrackets++;
} else if (token === ts.SyntaxKind.OpenBraceToken) {
unclosedBraces++;
} else if (token === ts.SyntaxKind.OpenParenToken) {
unclosedParens++;
} else if (token === ts.SyntaxKind.CloseBracketToken) {
unclosedBrackets--;
} else if (token === ts.SyntaxKind.CloseBraceToken) {
unclosedBraces--;
} else if (token === ts.SyntaxKind.CloseParenToken) {
unclosedParens--;
}
const isStringToken =
token === ts.SyntaxKind.StringLiteral ||
token === ts.SyntaxKind.NoSubstitutionTemplateLiteral;
const isCursorInToken =
scanner.getStartPos() <= offset &&
scanner.getStartPos() + scanner.getTokenText().length >= offset;
if (propertyAssignmentContext && isCursorInToken && isStringToken) {
if (
(propertyAssignmentContext || decoratorContext) &&
isStringToken(token) &&
isCursorInToken(scanner, offset)
) {
return true;
}
lastTokenText = scanner.getTokenText();
secondLastToken = lastToken;
lastToken = token;
token = scanner.scan();
token = scan();
}
return false;
@@ -164,3 +198,62 @@ function isPropertyAssignmentTerminator(token: ts.SyntaxKind) {
token === ts.SyntaxKind.CloseBraceToken
);
}
function isStringToken(token: ts.SyntaxKind): boolean {
return (
token === ts.SyntaxKind.StringLiteral || token === ts.SyntaxKind.NoSubstitutionTemplateLiteral
);
}
function isCursorInToken(scanner: ts.Scanner, offset: number): boolean {
return (
scanner.getStartPos() <= offset &&
scanner.getStartPos() + scanner.getTokenText().length >= offset
);
}
/**
* Creates a `ts.Scanner` for the given text along with a `scan` function that, unlike
* `ts.Scanner#scan`, handles template literals with substitutions correctly.
*
* The scanner has no notion of template substitutions on its own: it reports the `}` closing a
* `${...}` substitution as a plain `CloseBraceToken` and then treats the remainder of the template
* literal as the beginning of a new one, mis-scanning everything that follows it (see #65494). Just
* like the TypeScript parser, the returned `scan` function re-scans such braces as template
* continuations (`TemplateMiddle`/`TemplateTail`) instead.
*/
function createTemplateAwareScanner(text: string): {
scanner: ts.Scanner;
scan: () => ts.SyntaxKind;
} {
const scanner = ts.createScanner(ts.ScriptTarget.ESNext, true /* skipTrivia */);
scanner.setText(text);
// Brace depth at which each currently open template substitution started, innermost last.
const substitutionDepths: number[] = [];
let braceDepth = 0;
const scan = (): ts.SyntaxKind => {
let token = scanner.scan();
if (token === ts.SyntaxKind.OpenBraceToken) {
braceDepth++;
} else if (token === ts.SyntaxKind.CloseBraceToken) {
if (
substitutionDepths.length > 0 &&
braceDepth === substitutionDepths[substitutionDepths.length - 1]
) {
// This brace closes a template substitution rather than a block or object literal.
substitutionDepths.pop();
token = scanner.reScanTemplateToken(false /* isTaggedTemplate */);
} else {
braceDepth--;
}
}
if (token === ts.SyntaxKind.TemplateHead || token === ts.SyntaxKind.TemplateMiddle) {
substitutionDepths.push(braceDepth);
}
return token;
};
return {scanner, scan};
}
@@ -9,6 +9,7 @@ import type * as vscode from 'vscode';
import {DocumentUri, TextDocument} from 'vscode-languageserver-textdocument';
import {
isInsideStringLiteral,
isNotTypescriptOrSupportedDecoratorField,
isNotTypescriptOrSupportedDecoratorRange,
} from '../embedded_support';
@@ -90,6 +91,144 @@ describe('embedded language support', () => {
false,
);
});
it('inside styles declared before template', () => {
test(
`const foo = {styles: ['h1 { col¦or: red; }'], template: '<div></div>'}`,
isNotTypescriptOrSupportedDecoratorField,
true,
);
});
it('inside a string of another property after template', () => {
test(
`const foo = {template: '<div></div>', selector: 'app¦-root'}`,
isNotTypescriptOrSupportedDecoratorField,
false,
);
});
it('inside a string in the class body after a template in the decorator', () => {
test(
`@Component({template: '<div></div>'})
export class AppComponent {
private property = 'd¦oe';
}`,
isNotTypescriptOrSupportedDecoratorField,
false,
);
});
it('inside a string of a nested host property', () => {
test(
`const foo = {host: {'[class.foo]': 'is¦Foo'}, selector: 'app-root'}`,
isNotTypescriptOrSupportedDecoratorField,
true,
);
});
it('inside a @HostListener decorator string', () => {
test(
`@Component({template: '<div></div>'})
export class AppComponent {
@HostListener('cl¦ick', ['$event']) onClick(event: Event) {}
}`,
isNotTypescriptOrSupportedDecoratorField,
true,
);
});
it('inside a @HostBinding decorator string of a directive without template', () => {
test(
`@Directive({selector: '[foo]'})
export class FooDirective {
@HostBinding('class.f¦oo') isFoo = true;
}`,
isNotTypescriptOrSupportedDecoratorField,
true,
);
});
it('inside a string after a @HostListener decorator', () => {
test(
`@Component({template: '<div></div>'})
export class AppComponent {
@HostListener('click', ['$event']) onClick(event: Event) {
console.log('cli¦cked');
}
}`,
isNotTypescriptOrSupportedDecoratorField,
false,
);
});
it('inside a string argument of a plain function call named like a decorator', () => {
test(`HostListener('cl¦ick');`, isNotTypescriptOrSupportedDecoratorField, false);
});
it('after a template literal with substitutions', () => {
test(
`@Component({template: \`\`})
export class AppComponent {
private property = 'doe';
private method(): void {
console.log(\`\${this.property}\`);
this.¦
}
}`,
isNotTypescriptOrSupportedDecoratorField,
false,
);
});
it('after a template literal with nested braces in a substitution', () => {
test(
`@Component({template: \`\`})
export class AppComponent {
private method(): void {
console.log(\`\${{a: 1}.a} and \${[1, 2].map((x) => { return x; })}\`);
this.¦
}
}`,
isNotTypescriptOrSupportedDecoratorField,
false,
);
});
it('inside a template literal template with substitutions', () => {
test(
`const foo = {template: \`<div>\${value}</d¦iv>\`}`,
isNotTypescriptOrSupportedDecoratorField,
false,
);
});
it('inside a template literal template after a template literal with substitutions', () => {
test(
`const prefix = \`\${getPrefix()}-\`;
const foo = {template: \`<div>¦</div>\`}`,
isNotTypescriptOrSupportedDecoratorField,
true,
);
});
});
describe('isInsideStringLiteral', () => {
it('inside a string literal', () => {
test(`const foo = 'ba¦r';`, isInsideStringLiteral, true);
});
it('outside of a string literal', () => {
test(`const foo = 'bar'; const baz = 1¦;`, isInsideStringLiteral, false);
});
it('after a template literal with substitutions', () => {
test(`const foo = \`\${bar}\`; const baz = 1¦;`, isInsideStringLiteral, false);
});
it('inside a string literal after a template literal with substitutions', () => {
test(`const foo = \`\${bar}\`; const baz = 'qu¦x';`, isInsideStringLiteral, true);
});
});
describe('isNotTypescriptOrSupportedDecoratorRange', () => {