+ `,
+ styles: [
+ `
+ .container {
+ padding: 20px;
+ height: 100%;
+ max-height: 100%;
+ overflow-y: scroll;
+ }
+
+ .item-wrapper {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ grid-gap: 20px;
+ }
+
+ .movie-card {
+ background-color: #242333;
+ border-radius: 10px;
+ overflow: hidden;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ transition: transform 0.2s ease;
+ color: white;
+ height: 680px;
+ }
+
+ .movie-card img {
+ width: 100%;
+ height: 505px;
+ object-fit: cover;
+ }
+
+ .card-details {
+ padding: 15px;
+ }
+
+ .movie-title {
+ margin: 0;
+ font-size: 1.2rem;
+ margin-bottom: 10px;
+ }
+
+ .movie-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+ font-size: 0.9rem;
+ }
+
+ .overview {
+ font-size: 0.9rem;
+ line-height: 1.4;
+ }
+
+ .placeholder {
+ background-color: #242333;
+ height: 680px;
+ }
+ `,
+ ],
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: true,
+ imports: [
+ RxVirtualViewObserver,
+ RxVirtualView,
+ RxVirtualViewContent,
+ RxVirtualViewPlaceholder,
+ ],
+})
+export class VirtualViewCoolDemoComponent {
+ movies = movies;
+}
+
+const movies = [
+ {
+ adult: false,
+ backdrop_path: '/bQXAqRx2Fgc46uCVWgoPz5L5Dtr.jpg',
+ genre_ids: [28, 14, 878],
+ id: 436270,
+ original_language: 'en',
+ original_title: 'Black Adam',
+ overview:
+ 'Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods—and imprisoned just as quickly—Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the modern world.',
+ popularity: 6579.615,
+ poster_path: '/pFlaoHTZeyNkG83vxsAJiGzfSsa.jpg',
+ release_date: '2022-10-19',
+ title: 'Black Adam',
+ video: false,
+ vote_average: 7.3,
+ vote_count: 2508,
+ },
+ {
+ adult: false,
+ backdrop_path: '/7zQJYV02yehWrQN6NjKsBorqUUS.jpg',
+ genre_ids: [28, 18, 36],
+ id: 724495,
+ original_language: 'en',
+ original_title: 'The Woman King',
+ overview:
+ 'The story of the Agojie, the all-female unit of warriors who protected the African Kingdom of Dahomey in the 1800s with skills and a fierceness unlike anything the world has ever seen, and General Nanisca as she trains the next generation of recruits and readies them for battle against an enemy determined to destroy their way of life.',
+ popularity: 3881.892,
+ poster_path: '/438QXt1E3WJWb3PqNniK0tAE5c1.jpg',
+ release_date: '2022-09-15',
+ title: 'The Woman King',
+ video: false,
+ vote_average: 7.9,
+ vote_count: 615,
+ },
+ {
+ adult: false,
+ backdrop_path: '/au4HUSWDRadIcl9CqySlw1kJMfo.jpg',
+ genre_ids: [80, 28, 53],
+ id: 829799,
+ original_language: 'en',
+ original_title: 'Paradise City',
+ overview:
+ 'Renegade bounty hunter Ryan Swan must carve his way through the Hawaiian crime world to wreak vengeance on the kingpin who murdered his father.',
+ popularity: 1796.896,
+ poster_path: '/xdmmd437QdjcCls8yCQxrH5YYM4.jpg',
+ release_date: '2022-11-11',
+ title: 'Paradise City',
+ video: false,
+ vote_average: 6.3,
+ vote_count: 40,
+ },
+ {
+ adult: false,
+ backdrop_path: '/sUuzl04qNIYsnwCLQpZ2RSvXA1V.jpg',
+ genre_ids: [35, 28, 53],
+ id: 792775,
+ original_language: 'is',
+ original_title: 'Leynilögga',
+ overview:
+ "When Bússi, Iceland's toughest cop, is forced to work with a new partner to solve a series of bank robberies, the pressure to close the case as soon as possible proves too much for him.",
+ popularity: 1405.479,
+ poster_path: '/jnWyZsaCl3Ke6u6ReSmBRO8S1rX.jpg',
+ release_date: '2022-05-23',
+ title: 'Cop Secret',
+ video: false,
+ vote_average: 6.3,
+ vote_count: 33,
+ },
+ {
+ adult: false,
+ backdrop_path: '/kmzppWh7ljL6K9fXW72bPN3gKwu.jpg',
+ genre_ids: [14, 28, 35, 80],
+ id: 1013860,
+ original_language: 'en',
+ original_title: 'R.I.P.D. 2: Rise of the Damned',
+ overview:
+ 'When Sheriff Roy Pulsipher finds himself in the afterlife, he joins a special police force and returns to Earth to save humanity from the undead.',
+ popularity: 2530.599,
+ poster_path: '/g4yJTzMtOBUTAR2Qnmj8TYIcFVq.jpg',
+ release_date: '2022-11-15',
+ title: 'R.I.P.D. 2: Rise of the Damned',
+ video: false,
+ vote_average: 6.7,
+ vote_count: 207,
+ },
+ {
+ adult: false,
+ backdrop_path: '/707thQazLJiYLBhCrZlRoV05NNL.jpg',
+ genre_ids: [28, 18, 53],
+ id: 948276,
+ original_language: 'fr',
+ original_title: 'Balle perdue 2',
+ overview:
+ 'Having cleared his name, genius mechanic Lino has only one goal in mind: getting revenge on the corrupt cops who killed his brother and his mentor.',
+ popularity: 1277.701,
+ poster_path: '/uAeZI1JJbLPq7Bu5dziH7emHeu7.jpg',
+ release_date: '2022-11-10',
+ title: 'Lost Bullet 2',
+ video: false,
+ vote_average: 6.6,
+ vote_count: 148,
+ },
+ {
+ adult: false,
+ backdrop_path: '/90ZZIoWQLLEXSVm0ik3eEQBinul.jpg',
+ genre_ids: [28, 27, 53],
+ id: 988233,
+ original_language: 'en',
+ original_title: 'Hex',
+ overview:
+ 'Following a mysterious disappearance on a jump, a group of skydivers experience paranormal occurrences that leave them fighting for their lives.',
+ popularity: 1977.125,
+ poster_path: '/xFJHb43ZAnnuiDztxZYsmyopweb.jpg',
+ release_date: '2022-11-01',
+ title: 'Hex',
+ video: false,
+ vote_average: 5.1,
+ vote_count: 13,
+ },
+ {
+ adult: false,
+ backdrop_path: '/jCY35GkjwWUmoPO9EV1lWL6kuyj.jpg',
+ genre_ids: [28, 12, 53],
+ id: 855440,
+ original_language: 'es',
+ original_title: 'Polar',
+ overview:
+ 'MG, a policewoman who has been expelled from the Corps due to the problems with alcohol and drugs that she has had since the loss of her son, receives a call from a man asking her to look for Macarena Gómez, a popular TV actress.',
+ popularity: 1881.197,
+ poster_path: '/efuKHH9LqBZB67AS87kprLgaYO8.jpg',
+ release_date: '2022-10-26',
+ title: 'Polar',
+ video: false,
+ vote_average: 7.5,
+ vote_count: 2,
+ },
+ {
+ adult: false,
+ backdrop_path: '/vmDa8HijINCAFYKqsMz0YM3sVyE.jpg',
+ genre_ids: [80, 28, 53],
+ id: 747803,
+ original_language: 'en',
+ original_title: 'One Way',
+ overview:
+ 'On the run with a bag full of cash after a robbing his former crime boss—and a potentially fatal wound—Freddy slips onto a bus headed into the unrelenting California desert. With his life slipping through his fingers, Freddy is left with very few choices to survive.',
+ popularity: 1875.044,
+ poster_path: '/uQCxOziq79P3wDsRwQhhkhQyDsJ.jpg',
+ release_date: '2022-09-02',
+ title: 'One Way',
+ video: false,
+ vote_average: 6.5,
+ vote_count: 22,
+ },
+ {
+ adult: false,
+ backdrop_path: '/8Tr79lfoCkOYRg8SYwWit4OoQLi.jpg',
+ genre_ids: [878, 28],
+ id: 872177,
+ original_language: 'en',
+ original_title: 'Corrective Measures',
+ overview:
+ "Set in San Tiburon, the world's most dangerous maximum-security penitentiary and home to the world's most treacherous superpowered criminals, where tensions among the inmates and staff heighten, leading to anarchy that engulfs the prison and order is turned upside down.",
+ popularity: 1196.661,
+ poster_path: '/aHFq9NMhavOL0jtQvmHQ1c5e0ya.jpg',
+ release_date: '2022-04-29',
+ title: 'Corrective Measures',
+ video: false,
+ vote_average: 5.1,
+ vote_count: 35,
+ },
+ {
+ adult: false,
+ backdrop_path: '/sP1ShE4BGLkHSRqG9ZeGHg6C76t.jpg',
+ genre_ids: [53, 80],
+ id: 934641,
+ original_language: 'en',
+ original_title: 'The Minute You Wake Up Dead',
+ overview:
+ 'A stockbroker in a small southern town gets embroiled in an insurance scam with a next-door neighbor that leads to multiple murders when a host of other people want in on the plot. Sheriff Thurmond Fowler, the by-the-book town sheriff for over four decades, works earnestly to try and unravel the town’s mystery and winds up getting more than he bargained for.',
+ popularity: 1785.183,
+ poster_path: '/pUPwTbnAqfm95BZjNBnMMf39ChT.jpg',
+ release_date: '2022-11-04',
+ title: 'The Minute You Wake Up Dead',
+ video: false,
+ vote_average: 4.9,
+ vote_count: 21,
+ },
+ {
+ adult: false,
+ backdrop_path: '/rfnmMYuZ6EKOBvQLp2wqP21v7sI.jpg',
+ genre_ids: [35, 878, 12],
+ id: 774752,
+ original_language: 'en',
+ original_title: 'The Guardians of the Galaxy Holiday Special',
+ overview:
+ 'On a mission to make Christmas unforgettable for Quill, the Guardians head to Earth in search of the perfect present.',
+ popularity: 1329.347,
+ poster_path: '/8dqXyslZ2hv49Oiob9UjlGSHSTR.jpg',
+ release_date: '2022-11-25',
+ title: 'The Guardians of the Galaxy Holiday Special',
+ video: false,
+ vote_average: 7.5,
+ vote_count: 607,
+ },
+ {
+ adult: false,
+ backdrop_path: '/xDMIl84Qo5Tsu62c9DGWhmPI67A.jpg',
+ genre_ids: [28, 12, 878],
+ id: 505642,
+ original_language: 'en',
+ original_title: 'Black Panther: Wakanda Forever',
+ overview:
+ 'Queen Ramonda, Shuri, M’Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T’Challa’s death. As the Wakandans strive to embrace their next chapter, the heroes must band together with the help of War Dog Nakia and Everett Ross and forge a new path for the kingdom of Wakanda.',
+ popularity: 1798.687,
+ poster_path: '/ps2oKfhY6DL3alynlSqY97gHSsg.jpg',
+ release_date: '2022-11-09',
+ title: 'Black Panther: Wakanda Forever',
+ video: false,
+ vote_average: 7.5,
+ vote_count: 1213,
+ },
+ {
+ adult: false,
+ backdrop_path: '/c1bz69r0v065TGFA5nqBiKzPDys.jpg',
+ genre_ids: [35, 10751, 10402],
+ id: 830784,
+ original_language: 'en',
+ original_title: 'Lyle, Lyle, Crocodile',
+ overview:
+ 'When the Primm family moves to New York City, their young son Josh struggles to adapt to his new school and new friends. All of that changes when he discovers Lyle — a singing crocodile who loves baths, caviar and great music — living in the attic of his new home. But when Lyle’s existence is threatened by evil neighbor Mr. Grumps, the Primms must band together to show the world that family can come from the most unexpected places.',
+ popularity: 1131.919,
+ poster_path: '/irIS5Tn3TXjNi1R9BpWvGAN4CZ1.jpg',
+ release_date: '2022-10-07',
+ title: 'Lyle, Lyle, Crocodile',
+ video: false,
+ vote_average: 7.8,
+ vote_count: 137,
+ },
+ {
+ adult: false,
+ backdrop_path: '/kpUre8wWSXn3D5RhrMttBZa6w1v.jpg',
+ genre_ids: [35, 10751, 14],
+ id: 338958,
+ original_language: 'en',
+ original_title: 'Disenchanted',
+ overview:
+ 'Disillusioned with life in the city, feeling out of place in suburbia, and frustrated that her happily ever after hasn’t been so easy to find, Giselle turns to the magic of Andalasia for help. Accidentally transforming the entire town into a real-life fairy tale and placing her family’s future happiness in jeopardy, she must race against time to reverse the spell and determine what happily ever after truly means to her and her family.',
+ popularity: 1120.736,
+ poster_path: '/4x3pt6hoLblBeHebUa4OyiVXFiM.jpg',
+ release_date: '2022-11-16',
+ title: 'Disenchanted',
+ video: false,
+ vote_average: 7.3,
+ vote_count: 492,
+ },
+ {
+ adult: false,
+ backdrop_path: '/olPXihyFeeNvnaD6IOBltgIV1FU.jpg',
+ genre_ids: [27, 9648, 53],
+ id: 882598,
+ original_language: 'en',
+ original_title: 'Smile',
+ overview:
+ "After witnessing a bizarre, traumatic incident involving a patient, Dr. Rose Cotter starts experiencing frightening occurrences that she can't explain. As an overwhelming terror begins taking over her life, Rose must confront her troubling past in order to survive and escape her horrifying new reality.",
+ popularity: 1120.904,
+ poster_path: '/aPqcQwu4VGEewPhagWNncDbJ9Xp.jpg',
+ release_date: '2022-09-23',
+ title: 'Smile',
+ video: false,
+ vote_average: 6.8,
+ vote_count: 1043,
+ },
+ {
+ adult: false,
+ backdrop_path: '/5aSvzECXrtABcIh7fZYkH2K6ttC.jpg',
+ genre_ids: [28, 53, 80],
+ id: 972313,
+ original_language: 'en',
+ original_title: 'Blowback',
+ overview:
+ "When a master thief is sabotaged during a bank heist and left for dead, he seeks revenge on his former crew one target at a time. Now, with the cops and the mob closing in, he's in the race of his life to reclaim an untold fortune in cryptocurrency from those who double-crossed him.",
+ popularity: 1324.392,
+ poster_path: '/fHQHC32dhom8u0OxC2hs2gYQh0M.jpg',
+ release_date: '2022-06-17',
+ title: 'Blowback',
+ video: false,
+ vote_average: 6,
+ vote_count: 21,
+ },
+ {
+ adult: false,
+ backdrop_path: null,
+ genre_ids: [10749],
+ id: 485470,
+ original_language: 'ko',
+ original_title: '착한 형수2',
+ overview:
+ "If you give it once, a good brother-in-law who gives everything generously will come! At the house of her girlfriend Jin-kyung, who lives with pumice stone, her brother and his wife suddenly visit and the four of them live together. At first, Kyung-seok, who was burdened by his girlfriend's brother, began to keep his eyes on his wife, Yeon-su. A bold brother-in-law who walks around in no-bra and panties without hesitation even at his sister-in-law's house. Besides, from a certain moment, he starts to send a hand of temptation to Pyeong-seok first...",
+ popularity: 545.569,
+ poster_path: '/3pEs4hmeHvTAsmx09whEaPDOQpq.jpg',
+ release_date: '2017-10-08',
+ title: 'Nice Sister-In-Law 2',
+ video: false,
+ vote_average: 6,
+ vote_count: 2,
+ },
+ {
+ adult: false,
+ backdrop_path: '/eyiSLRh44SKKWIJ6bxWq8z1sscB.jpg',
+ genre_ids: [53, 27, 80],
+ id: 899294,
+ original_language: 'en',
+ original_title: 'Frank and Penelope',
+ overview:
+ 'A tale of love and violence when a man on his emotional last legs finds a savior seductively dancing in a run-down strip club. And a life most certainly headed off a cliff suddenly becomes redirected - as everything is now worth dying for.',
+ popularity: 879.196,
+ poster_path: '/5NpXoAi3nEQkEgLO09nmotPfyNa.jpg',
+ release_date: '2022-06-03',
+ title: 'Frank and Penelope',
+ video: false,
+ vote_average: 7.8,
+ vote_count: 44,
+ },
+ {
+ adult: false,
+ backdrop_path: '/yNib9QAiyaopUJbaayKQ2xK7mYf.jpg',
+ genre_ids: [18, 28, 10752],
+ id: 966220,
+ original_language: 'uk',
+ original_title: 'Снайпер. Білий ворон',
+ overview:
+ 'Mykola is an eccentric pacifist who wants to be useful to humanity. When the war begins at Donbass, Mykola’s naive world is collapsing as the militants kill his pregnant wife and burn his home to the ground. Recovered, he makes a cardinal decision and gets enlisted in a sniper company. Having met his wife’s killers, he emotionally breaks down and arranges “sniper terror” for the enemy. He’s saved from a senseless death by his instructor who himself gets mortally wounded. The death of a friend leaves a “scar” and Mykola is ready to sacrifice his life.',
+ popularity: 960.86,
+ poster_path: '/lZOODJzwuQo0etJJyBBZJOSdZcW.jpg',
+ release_date: '2022-05-03',
+ title: 'Sniper: The White Raven',
+ video: false,
+ vote_average: 7.7,
+ vote_count: 146,
+ },
+];
diff --git a/apps/demos/src/app/features/template/virtual-view/virtual-view-demo.component.ts b/apps/demos/src/app/features/template/virtual-view/virtual-view-demo.component.ts
new file mode 100644
index 0000000000..e69f95466e
--- /dev/null
+++ b/apps/demos/src/app/features/template/virtual-view/virtual-view-demo.component.ts
@@ -0,0 +1,196 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ viewChild,
+ ViewEncapsulation,
+} from '@angular/core';
+import {
+ RxVirtualView,
+ RxVirtualViewObserver,
+ RxVirtualViewPlaceholder,
+ RxVirtualViewContent,
+} from '@rx-angular/template/virtual-view';
+import { VirtualContent } from './virtual-content.component';
+import { VirtualItem } from './virtual-item.component';
+import { VirtualPlaceholder } from './virtual-placeholder.component';
+
+@Component({
+ selector: 'virtual-view-demo',
+ template: `
+
+
+
+
+
+
+
Inline, no placeholder, keepLastKnownSize
+ @for (item of values; track item.id) {
+
+
+ {{ item.content }}
+
+
+ }
+
+
+
Inline, with placeholder
+ @for (item of values; track item.id) {
+
+
content before
+
+ {{ item.content }}
+
+
content after
+
+ {{ item.content }}
+
+
+ }
+
+
+
Inline, startWithPlaceholderAsap
+ @for (item of values; track item.id) {
+
+
content before
+
+ {{ item.content }}
+
+
content after
+
+ {{ item.content }}
+
+
+ }
+
+
+
With Components
+ @for (item of values; track item.id) {
+
+
+
+
+ }
+
+
+
On Component (embedded)
+ @for (item of values; track item.id) {
+
+ }
+
+
+
Category 3
+ @for (item of values; track item.id) {
+
+
+ {{ item.content }}
+
+
+ {{ item.content }}
+
+
+ }
+
+
+
Category 4
+ @for (item of values; track item.id) {
+
+
+ {{ item.content }}
+
+
+ {{ item.content }}
+
+
+ }
+
+
+ `,
+ styles: [
+ `
+ .container {
+ height: 100%;
+ max-height: 100%;
+ overflow-y: scroll;
+ }
+ .item-wrapper {
+ height: 500px;
+ width: 400px;
+ overflow: auto;
+ }
+
+ .content.placeholder {
+ color: blue;
+ }
+
+ .item {
+ display: block;
+ width: 250px;
+ /*overflow: hidden;
+ flex-shrink: 0;*/
+ /*height: 50px;*/
+ /*will-change: transform;*/
+ border: 1px solid green;
+ padding: 10px 0;
+ box-shadow: 1px 1px 1px 1px rgba(0, 0, 0, 0.13);
+ }
+ `,
+ ],
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: true,
+ imports: [
+ RxVirtualViewObserver,
+ RxVirtualView,
+ RxVirtualViewContent,
+ RxVirtualViewPlaceholder,
+ VirtualPlaceholder,
+ VirtualContent,
+ VirtualItem,
+ ],
+})
+export class VirtualViewDemoComponent {
+ observer = viewChild(RxVirtualViewObserver);
+ values = new Array<{ id: number; content: string }>(200)
+ .fill(null)
+ .map((v, id) => ({
+ id,
+ content: randomContent(),
+ }));
+}
+
+const randomContent = () => {
+ return new Array(Math.max(1, Math.floor(Math.random() * 25)))
+ .fill('')
+ .map(() => randomWord())
+ .join(' ');
+};
+
+const randomWord = () => {
+ const words = [
+ 'Apple',
+ 'Banana',
+ 'The',
+ 'Orange',
+ 'House',
+ 'Boat',
+ 'Lake',
+ 'Car',
+ 'And',
+ ];
+ return words[Math.floor(Math.random() * words.length)];
+};
diff --git a/apps/demos/src/app/features/template/virtual-view/virtual-view.menu.ts b/apps/demos/src/app/features/template/virtual-view/virtual-view.menu.ts
new file mode 100644
index 0000000000..7e51916300
--- /dev/null
+++ b/apps/demos/src/app/features/template/virtual-view/virtual-view.menu.ts
@@ -0,0 +1,6 @@
+export const VIRTUAL_VIEW_MENU_ITEMS = [
+ {
+ label: 'Basic Example',
+ link: 'basic-example',
+ },
+];
diff --git a/apps/demos/src/app/features/template/virtual-view/virtual-view.routes.ts b/apps/demos/src/app/features/template/virtual-view/virtual-view.routes.ts
new file mode 100644
index 0000000000..07b2aa75b8
--- /dev/null
+++ b/apps/demos/src/app/features/template/virtual-view/virtual-view.routes.ts
@@ -0,0 +1,23 @@
+import { Routes } from '@angular/router';
+
+export const VIRTUAL_VIEW_ROUTES: Routes = [
+ {
+ path: '',
+ redirectTo: 'basic-example',
+ pathMatch: 'full',
+ },
+ {
+ path: 'basic-example',
+ loadComponent: () =>
+ import('./virtual-view-demo.component').then(
+ (m) => m.VirtualViewDemoComponent,
+ ),
+ },
+ {
+ path: 'cool-example',
+ loadComponent: () =>
+ import('./virtual-view-cool-demo.component').then(
+ (m) => m.VirtualViewCoolDemoComponent,
+ ),
+ },
+];
diff --git a/apps/docs/docs/state/integrations/manage-entities-using-ngrx-entity.md b/apps/docs/docs/state/integrations/manage-entities-using-ngrx-entity.mdx
similarity index 60%
rename from apps/docs/docs/state/integrations/manage-entities-using-ngrx-entity.md
rename to apps/docs/docs/state/integrations/manage-entities-using-ngrx-entity.mdx
index 66b4134524..9214f59243 100644
--- a/apps/docs/docs/state/integrations/manage-entities-using-ngrx-entity.md
+++ b/apps/docs/docs/state/integrations/manage-entities-using-ngrx-entity.mdx
@@ -4,6 +4,9 @@ title: Manage entities using @ngrx/entity
# Renamed from libs/state/docs/snippets/manage-collections-with-ngrx-entity.md
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
_Author: [@Phhansen](https://github.com/Phhansen)_
# Manage entities using `@ngrx/entity`
@@ -28,12 +31,19 @@ interface ComponentState {
Now, if we want to add one item to our array _(in an immutable way)_, we replace the `items` array in the state with a new reference.
+
+
+
+
```typescript
+import { RxState } from '@rx-angular/state';
+import { Component } from '@angular/core';
+
@Component({
selector: 'my-component',
})
-export class MyComponent extends RxState {
- readonly addItem$ = new Subject();
+export class MyComponent {
+ addItem$ = new Subject();
constructor() {
super();
@@ -53,6 +63,38 @@ export class MyComponent extends RxState {
}
```
+
+
+
+
+```typescript
+@Component({
+ selector: 'my-component',
+})
+export class MyComponent {
+ readonly #state = rxState();
+ readonly addItem$ = new Subject();
+
+ constructor() {
+ this.#state.connect(this.addItem$, (oldState, itemName) => {
+ const newItem = {
+ id: uuid(), // unique hash generation fn()
+ name: itemName,
+ };
+
+ return {
+ ...oldState,
+ items: [...oldState.items, newItem],
+ };
+ });
+ }
+}
+```
+
+
+
+
+
If we want to update one item, we have to query the `items` array first to get a hold of the item and then construct a new array again.
What about deleting an item? You get the picture. **It´s a lot of code**, and it will grow even more if we have several types of collections in our state.
@@ -63,8 +105,8 @@ Now let us see how our code will look when using `@ngrx/entity`.
```typescript
interface Item {
- id: string;
- name: string;
+ string;
+ string;
}
interface ComponentState extends EntityState {
@@ -80,6 +122,10 @@ The entity adapter needs a `selectId` function which is used to query items by `
Now let's see how the component has changed:
+
+
+
+
```typescript
@Component({
selector: 'my-component',
@@ -90,13 +136,33 @@ export class MyComponent extends RxState {
constructor() {
super();
- this.connect(this.addItem$, (oldState, itemName) =>
- adapter.addOne({ id: uuid(), name: itemName() }, oldState)
- );
+ this.connect(this.addItem$, (oldState, itemName) => adapter.addOne({ id: uuid(), name: itemName() }, oldState));
}
}
```
+
+
+
+
+```typescript
+@Component({
+ selector: 'my-component',
+})
+export class MyComponent {
+ readonly #state = rxState();
+ readonly addItem$ = new Subject();
+
+ constructor() {
+ this.#state.connect(this.addItem$, (oldState, itemName) => adapter.addOne({ id: uuid(), name: itemName() }, oldState));
+ }
+}
+```
+
+
+
+
+
The `addOne()` function is just one of many functions that help us manipulate the collection.
Delete an item? `removeOne(item.id, oldState)`.
@@ -107,11 +173,14 @@ Check out the [full list of adapter collection methods](https://ngrx.io/guide/en
The entity adapter comes with a small set of default selectors we can use right out of the box.
+
+
+
+
```typescript
import { select } from '@ngrx/store';
-const { selectIds, selectEntities, selectAll, selectTotal } =
- adapter.getSelectors();
+const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
@Component({
selector: 'my-component',
@@ -124,3 +193,25 @@ export class MyComponent extends RxState {
}
}
```
+
+
+
+
+
+```typescript
+import { select } from '@ngrx/store';
+
+const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
+
+@Component({
+ selector: 'my-component',
+})
+export class MyComponent {
+ readonly #state = rxState();
+ readonly items$ = this.#state.select(select(selectAll));
+}
+```
+
+
+
+
diff --git a/apps/docs/docs/state/integrations/resuse-ngrx-selectors-to-compose-state.md b/apps/docs/docs/state/integrations/resuse-ngrx-selectors-to-compose-state.mdx
similarity index 68%
rename from apps/docs/docs/state/integrations/resuse-ngrx-selectors-to-compose-state.md
rename to apps/docs/docs/state/integrations/resuse-ngrx-selectors-to-compose-state.mdx
index c05f3f2378..ea8ec0722a 100644
--- a/apps/docs/docs/state/integrations/resuse-ngrx-selectors-to-compose-state.md
+++ b/apps/docs/docs/state/integrations/resuse-ngrx-selectors-to-compose-state.mdx
@@ -5,6 +5,9 @@ title: Reusing ngrx selectors to compose state
# Renamed from libs/state/docs/snippets/composing-state-using-ngrx-selectors.md
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
_Author: [@Phhansen](https://github.com/Phhansen)_
# Reusing ngrx selectors to compose state
@@ -32,15 +35,15 @@ const selectItems = (state: ComponentState) => state.items;
const selectVisibleIds = (state: ComponentState) => state.visibleIds;
-const selectVisibleItems = createSelector(
- selectVisibleIds,
- selectItems,
- (visibleIds, items) => visibleIds.map((id) => items[id])
-);
+const selectVisibleItems = createSelector(selectVisibleIds, selectItems, (visibleIds, items) => visibleIds.map((id) => items[id]));
```
Using this in our component will look like this:
+
+
+
+
```typescript
import { select } from '@ngrx/store';
@@ -53,3 +56,21 @@ export class ItemListComponent extends RxState {
}
}
```
+
+
+
+
+
+```typescript
+import { select } from '@ngrx/store';
+
+@Component()
+export class ItemListComponent {
+ readonly #state = rxState();
+ readonly visibleItems$ = this.#state.select(select(selectVisibleItems));
+}
+```
+
+
+
+
diff --git a/apps/docs/docs/state/recipes/load-data-on-route-change.md b/apps/docs/docs/state/recipes/load-data-on-route-change.mdx
similarity index 63%
rename from apps/docs/docs/state/recipes/load-data-on-route-change.md
rename to apps/docs/docs/state/recipes/load-data-on-route-change.mdx
index 3e4d974a9c..2c08f6c578 100644
--- a/apps/docs/docs/state/recipes/load-data-on-route-change.md
+++ b/apps/docs/docs/state/recipes/load-data-on-route-change.mdx
@@ -4,6 +4,9 @@ title: Load data on route change
# Renamed from libs/state/docs/snippets/loading-state-and-data-fetching.md
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
# Load data on route change
On every URL change fetch users from the back end and deal with loading flags
@@ -48,6 +51,10 @@ export class MyComponent {
## Reactive
+
+
+
+
```typescript
@Component({
selector: 'my-comp',
@@ -64,18 +71,58 @@ export class MyComponent {
constructor(
private router: Router,
private userService: UserService,
- private state: RxState<{ user: string; isLoading: boolean }>
+ private state: RxState<{ user: string; isLoading: boolean }>,
) {
const fetchUserOnUrlChange$ = this.router.params.pipe(
switchMap((p) =>
this.userService.getUser(p.user).pipe(
map((res) => ({ user: res.user })),
startWith({ isLoading: true }),
- endWith({ isLoading: false })
- )
- )
+ endWith({ isLoading: false }),
+ ),
+ ),
);
this.state.connect(fetchUserOnUrlChange$);
}
}
```
+
+
+
+
+
+```typescript
+@Component({
+ selector: 'my-comp',
+ template: `
+
+
{{ user$ | push }}
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class MyComponent {
+ readonly #state = rxState<{ user: string; isLoading: boolean }>();
+ readonly user$ = this.#state.select('user');
+ readonly isLoading$ = this.#state.select('isLoading');
+
+ constructor(
+ private router: Router,
+ private userService: UserService,
+ ) {
+ const fetchUserOnUrlChange$ = this.router.params.pipe(
+ switchMap((p) =>
+ this.userService.getUser(p.user).pipe(
+ map((res) => ({ user: res.user })),
+ startWith({ isLoading: true }),
+ endWith({ isLoading: false }),
+ ),
+ ),
+ );
+ this.#state.connect(fetchUserOnUrlChange$);
+ }
+}
+```
+
+
+
+
diff --git a/apps/docs/docs/state/recipes/manage-viewmodel.md b/apps/docs/docs/state/recipes/manage-viewmodel.mdx
similarity index 64%
rename from apps/docs/docs/state/recipes/manage-viewmodel.md
rename to apps/docs/docs/state/recipes/manage-viewmodel.mdx
index 4e2adda670..6be2298cdc 100644
--- a/apps/docs/docs/state/recipes/manage-viewmodel.md
+++ b/apps/docs/docs/state/recipes/manage-viewmodel.mdx
@@ -5,6 +5,9 @@ title: Selecting the ViewModel
# Renamed from libs/state/docs/snippets/selecting-the-viewmodel.md
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
# Selecting the ViewModel
Here are some useful strategies to properly handle `ViewModels` with `@rx-angular/state`. In this examples we will use standalone [`selectSlice`](../api/rxjs-operators/select-slice.md) operator.
@@ -58,6 +61,10 @@ It returns an Observable that emits a distinct subset of the received object.
Utilizing it inside of the `RxState#select` method enables you to pluck a _distinct_ `ViewModel` directly out of your state.
+
+
+
+
```typescript
@Component()
export class ViewModelComponent extends RxState {
@@ -67,10 +74,8 @@ export class ViewModelComponent extends RxState {
title,
created,
total: list.length,
- visibleItems: list.filter((item) =>
- visibleItemIds.some((itemId) => itemId === item.id)
- ),
- }))
+ visibleItems: list.filter((item) => visibleItemIds.some((itemId) => itemId === item.id)),
+ })),
);
constructor() {
super();
@@ -78,6 +83,30 @@ export class ViewModelComponent extends RxState {
}
```
+
+
+
+
+```typescript
+@Component()
+export class ViewModelComponent {
+ readonly #state = rxState();
+ readonly viewModel$: Observable = this.#state.select(
+ selectSlice(['title', 'list', 'created', 'visibleItemIds']),
+ map(({ title, list, created, visibleItemIds }) => ({
+ title,
+ created,
+ total: list.length,
+ visibleItems: list.filter((item) => visibleItemIds.some((itemId) => itemId === item.id)),
+ })),
+ );
+}
+```
+
+
+
+
+
## Multiple Observables and selectSlice:
There are situations where you want to divide your `ViewModel` into different parts.
@@ -100,6 +129,10 @@ This way you may achieve more control over what to render when, e.g. lazy render
```
+
+
+
+
```typescript
interface ComponentViewModel {
main$: Observable<{ title: string; created: Date }>;
@@ -114,10 +147,8 @@ export class ViewModelComponent extends RxState {
selectSlice(['list', 'visibleItemIds']),
map(({ list, visibleItemIds }) => ({
total: list.length,
- visibleItems: list.filter((item) =>
- visibleItemIds.some((itemId) => itemId === item.id)
- ),
- }))
+ visibleItems: list.filter((item) => visibleItemIds.some((itemId) => itemId === item.id)),
+ })),
),
};
constructor() {
@@ -125,3 +156,33 @@ export class ViewModelComponent extends RxState {
}
}
```
+
+
+
+
+
+```typescript
+interface ComponentViewModel {
+ main$: Observable<{ title: string; created: Date }>;
+ list$: Observable<{ total: number; visibleItems: Item[] }>;
+}
+
+@Component()
+export class ViewModelComponent {
+ readonly #state = rxState();
+ readonly viewModel: ComponentViewModel = {
+ main$: this.#state.select(selectSlice(['title', 'created'])),
+ list$: this.#state.select(
+ selectSlice(['list', 'visibleItemIds']),
+ map(({ list, visibleItemIds }) => ({
+ total: list.length,
+ visibleItems: list.filter((item) => visibleItemIds.some((itemId) => itemId === item.id)),
+ })),
+ ),
+ };
+}
+```
+
+
+
+
diff --git a/apps/docs/docs/state/recipes/run-partial-updates.md b/apps/docs/docs/state/recipes/run-partial-updates.mdx
similarity index 54%
rename from apps/docs/docs/state/recipes/run-partial-updates.md
rename to apps/docs/docs/state/recipes/run-partial-updates.mdx
index 879d885a56..82efe3e510 100644
--- a/apps/docs/docs/state/recipes/run-partial-updates.md
+++ b/apps/docs/docs/state/recipes/run-partial-updates.mdx
@@ -5,6 +5,9 @@ title: How can I run partial updates?
# Renamed from libs/state/docs/snippets/how-can-i-run-partial-state-updates.md
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
# How can I run partial updates?
`RxState` has partial updates built in. Every change sent to the state over `set` or `connect` is treated as partial update.
@@ -12,6 +15,10 @@ An instance of `RxState` typed with `T` accepts `Partial` in the `set` and `c
The partial update can happen directly by providing a `Partial` or over a reduce function `(oldState, change) => newState`.
+
+
+
+
```typescript
import { RxState } from `rx-angular/state`;
interface ComponentState {
@@ -21,20 +28,47 @@ interface ComponentState {
}
class AnyComponent extends RxState {
+updateTitle() {
+this.set({ title: 'Hello!' });
+}
+
+resetList() {
+this.connect(this.globalState$.list$({ list: [], loading: false }));
+}
+}
+
+```
+
+
+
+
+
+```typescript
+import { rxState } from `rx-angular/state`;
+interface ComponentState {
+ title: string;
+ list: string[];
+ loading: boolean;
+}
+
+class AnyComponent {
+ readonly #state = rxState();
updateTitle() {
this.set({ title: 'Hello!' });
}
resetList() {
- this.connect(this.globalState$.list$({ list: [], loading: false }));
+ this.#state.connect(this.globalState$.list$({ list: [], loading: false }));
}
}
```
+
+
+
+
Internally the state update looks like this:
```typescript
-newState$.pipe(
- scan((oldState, newPartialState) => ({ ...oldState, ...newPartialState }))
-);
+newState$.pipe(scan((oldState, newPartialState) => ({ ...oldState, ...newPartialState })));
```
diff --git a/apps/docs/docs/state/recipes/use-rxstate-as-global-state.md b/apps/docs/docs/state/recipes/use-rxstate-as-global-state.mdx
similarity index 55%
rename from apps/docs/docs/state/recipes/use-rxstate-as-global-state.md
rename to apps/docs/docs/state/recipes/use-rxstate-as-global-state.mdx
index 13316c32a0..df03d5798e 100644
--- a/apps/docs/docs/state/recipes/use-rxstate-as-global-state.md
+++ b/apps/docs/docs/state/recipes/use-rxstate-as-global-state.mdx
@@ -5,6 +5,9 @@ title: How to manage global state
# Renamed from libs/state/docs/snippets/manage-global-state.md
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
_Author: [@Phhansen](https://github.com/Phhansen)_
# How to manage global state
@@ -21,6 +24,10 @@ As with the global/local state snippet, we'll be doing the same example to-do ap
- The list can be expanded or collapsed and has property `isExpanded`.
- Gets tasks array from endpoint _tasks/get_ and filters out tasks that already answered.
+
+
+
+
```typescript
interface TodosState {
tasks: Task[];
@@ -35,7 +42,7 @@ export class TodoComponent extends RxState {
readonly tasks$ = this.select('tasks');
readonly counter$ = this.select(
map((state) => state.tasks),
- map((tasks) => tasks.length)
+ map((tasks) => tasks.length),
);
readonly isExpanded$ = this.select('isExpanded');
@@ -43,22 +50,55 @@ export class TodoComponent extends RxState {
super();
/* Filter out tasks that are done */
- this.connect(
- 'tasks',
- this.tasksService
- .fetchTasks()
- .pipe(filter((tasks) => tasks.filter((task) => !task.done)))
- );
+ this.connect('tasks', this.tasksService.fetchTasks().pipe(filter((tasks) => tasks.filter((task) => !task.done))));
}
}
```
+
+
+
+
+```typescript
+interface TodosState {
+ tasks: Task[];
+ isExpanded: boolean;
+}
+
+@Component({
+ selector: 'todos',
+ templateUrl: './todo.component.html',
+})
+export class TodoComponent {
+ readonly #state = rxState();
+ readonly tasks$ = this.#state.select('tasks');
+ readonly counter$ = this.#state.select(
+ map((state) => state.tasks),
+ map((tasks) => tasks.length),
+ );
+ readonly isExpanded$ = this.#state.select('isExpanded');
+
+ constructor(private tasksService: TasksService) {
+ /* Filter out tasks that are done */
+ this.#state.connect('tasks', this.tasksService.fetchTasks().pipe(filter((tasks) => tasks.filter((task) => !task.done))));
+ }
+}
+```
+
+
+
+
+
### Setup
- Renders a list of all existing `tasks` and a `counter` that shows the total amount of tasks.
- The list can be expanded or collapsed and has property `isExpanded`.
- Gets tasks as array from endpoint _tasks/get_.
+
+
+
+
```typescript
interface AllTodosState {
tasks: Task[];
@@ -73,7 +113,7 @@ export class AllTasksComponent extends RxState {
readonly tasks$ = this.select('tasks');
readonly counter$ = this.select(
map((state) => state.tasks),
- map((tasks) => tasks.length)
+ map((tasks) => tasks.length),
);
readonly isExpanded$ = this.select('isExpanded');
@@ -86,6 +126,40 @@ export class AllTasksComponent extends RxState {
}
```
+
+
+
+
+```typescript
+interface AllTodosState {
+ tasks: Task[];
+ isExpanded: boolean;
+}
+
+@Component({
+ selector: 'all-tasks',
+ templateUrl: './all-tasks.component.html',
+})
+export class AllTasksComponent {
+ readonly #state = rxState();
+ readonly tasks$ = this.#state.select('tasks');
+ readonly counter$ = this.#state.select(
+ map((state) => state.tasks),
+ map((tasks) => tasks.length),
+ );
+ readonly isExpanded$ = this.#state.select('isExpanded');
+
+ constructor(private tasksService: TasksService) {
+ /* Fetch tasks from backend */
+ this.#state.connect('tasks', this.tasksService.fetchTasks());
+ }
+}
+```
+
+
+
+
+
### What is global and what is local?
Looking at the above examples, let us see what is **local** and what is **global**!
@@ -112,13 +186,35 @@ export interface GlobalState {
tasks: Task[];
}
-export const GLOBAL_RX_STATE = new InjectionToken>(
- 'GLOBAL_RX_STATE'
-);
+export const GLOBAL_RX_STATE = new InjectionToken>('GLOBAL_RX_STATE');
```
We then _provide_ the `injectionToken` in our `app.module.ts`.
+
+
+
+
+```typescript
+import { GLOBAL_RX_STATE, GlobalState } from "./rx-state";
+...
+
+@NgModule({
+imports: [...],
+declarations: [...],
+providers: [{
+provide: GLOBAL_RX_STATE, useFactory: () => new RxState()
+}],
+bootstrap: [...]
+})
+export class AppModule {}
+
+```
+
+
+
+
+
```typescript
import { GLOBAL_RX_STATE, GlobalState } from "./rx-state";
...
@@ -127,13 +223,17 @@ import { GLOBAL_RX_STATE, GlobalState } from "./rx-state";
imports: [...],
declarations: [...],
providers: [{
- provide: GLOBAL_RX_STATE, useFactory: () => new RxState()
+ provide: GLOBAL_RX_STATE, useFactory: () => rxState()
}],
bootstrap: [...]
})
export class AppModule {}
```
+
+
+
+
We can then load the `tasks` in the `AppComponent` via our `tasksService.fetchTasks()` and just have our `TodoComponent` and `AllTasksComponent` connect to the global state.
```typescript
@@ -153,6 +253,10 @@ constructor(@Inject(GLOBAL_RX_STATE) private state, private tasksService: TasksS
And our updated `TodoComponent`
+
+
+
+
```typescript
interface TodosState {
tasks: Task[];
@@ -167,30 +271,61 @@ export class TodoComponent extends RxState {
readonly tasks$ = this.select('tasks');
readonly counter$ = this.select(
map((state) => state.tasks),
- map((tasks) => tasks.length)
+ map((tasks) => tasks.length),
);
readonly isExpanded$ = this.select('isExpanded');
- constructor(
- @Inject(GLOBAL_RX_STATE) private globalState: RxState
- ) {
+ constructor(@Inject(GLOBAL_RX_STATE) private globalState: RxState) {
super();
/* Connect to global state and filter out already completed tasks */
- this.connect(
- 'tasks',
- this.globalState
- .select('tasks')
- .pipe(map((tasks) => tasks.filter((task) => !task.done)))
- );
+ this.connect('tasks', this.globalState.select('tasks').pipe(map((tasks) => tasks.filter((task) => !task.done))));
}
}
```
+
+
+
+
+```typescript
+interface TodosState {
+ tasks: Task[];
+ isExpanded: boolean;
+}
+
+@Component({
+ selector: 'todos',
+ templateUrl: './todo.component.html',
+})
+export class TodoComponent {
+ readonly #state = rxState();
+ readonly tasks$ = this.#state.select('tasks');
+ readonly counter$ = this.#state.select(
+ map((state) => state.tasks),
+ map((tasks) => tasks.length),
+ );
+ readonly isExpanded$ = this.#state.select('isExpanded');
+
+ constructor(@Inject(GLOBAL_RX_STATE) private globalState: RxState) {
+ /* Connect to global state and filter out already completed tasks */
+ this.#state.connect('tasks', this.globalState.select('tasks').pipe(map((tasks) => tasks.filter((task) => !task.done))));
+ }
+}
+```
+
+
+
+
+
Here we `connect` to the global state instance and filter out the already completed tasks.
Our `AllTasksComponent` is slightly different in that it doesn't actually need to filter anything, and thus it only needs to manage the **local** `isExpanded` value, and just have the `tasks` and `counter` values come directly from the **global** state.
+
+
+
+
```typescript
interface AllTodosState {
isExpanded: boolean;
@@ -204,14 +339,42 @@ export class AllTasksComponent extends RxState {
readonly tasks$ = this.globalState.select('tasks');
readonly counter$ = this.globalState.select(
map((state) => state.tasks),
- map((tasks) => tasks.length)
+ map((tasks) => tasks.length),
);
readonly isExpanded$ = this.select('isExpanded');
- constructor(
- @Inject(GLOBAL_RX_STATE) private globalState: RxState
- ) {
+ constructor(@Inject(GLOBAL_RX_STATE) private globalState: RxState) {
super();
}
}
```
+
+
+
+
+
+```typescript
+interface AllTodosState {
+ isExpanded: boolean;
+}
+
+@Component({
+ selector: 'all-tasks',
+ templateUrl: './all-tasks.component.html',
+})
+export class AllTasksComponent {
+ readonly #state = rxState();
+ readonly tasks$ = this.globalState.select('tasks');
+ readonly counter$ = this.globalState.select(
+ map((state) => state.tasks),
+ map((tasks) => tasks.length),
+ );
+ readonly isExpanded$ = this.#state.select('isExpanded');
+
+ constructor(@Inject(GLOBAL_RX_STATE) private globalState: RxState) {}
+}
+```
+
+
+
+
diff --git a/apps/docs/docs/state/recipes/work-with-hostbindings.md b/apps/docs/docs/state/recipes/work-with-hostbindings.mdx
similarity index 67%
rename from apps/docs/docs/state/recipes/work-with-hostbindings.md
rename to apps/docs/docs/state/recipes/work-with-hostbindings.mdx
index 7f85f2585d..89505fb104 100644
--- a/apps/docs/docs/state/recipes/work-with-hostbindings.md
+++ b/apps/docs/docs/state/recipes/work-with-hostbindings.mdx
@@ -5,6 +5,9 @@ title: HostBindings
# Renamed from libs/state/docs/snippets/hostbindings.md
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
# HostBindings
Some examples how to reactively handle basic [`HostBindings`](https://angular.io/api/core/HostBinding) with `@rx-angular/state` `RxState`.
@@ -36,6 +39,10 @@ As stated in the title, we have to be aware changeDetection. On every changeDete
all `HostBindings`. If our component doesn't get flagged as dirty, our `HostBindings` won't get updated. So we have to make
sure that state changes that are related to the `HostBindings` value are actually triggering a re-render.
+
+
+
+
```typescript
@Component({
providers: [RxState],
@@ -58,6 +65,33 @@ export class RxComponent {
}
```
+
+
+
+
+```typescript
+@Component({...})
+export class RxComponent {
+ readonly #state = rxState()
+ // Modifying the class
+ @HostBinding('[class.is-hidden]') get isHidden() {
+ return !this.#state.get().visible;
+ }
+ // Modifying styles
+ @HostBinding('[style.marginTop]') get marginTop() {
+ return `${this.#state.get().top}px`;
+ }
+ // Modifying styles
+ @HostBinding('[style.maxHeight]') get maxHeight() {
+ return `${this.#state.get().maxHeight}px`;
+ }
+}
+```
+
+
+
+
+
With this setup in place we have two options to get things done.
### Call ChangeDetection manually
@@ -65,6 +99,10 @@ With this setup in place we have two options to get things done.
Since rendering is a side-effect, we could utilize the `hold` method and register
a function which handles change detection for us.
+
+
+
+
```typescript
@Component({
providers: [RxState],
@@ -85,13 +123,47 @@ export class RxComponent {
constructor(
private state: RxState,
- private cdRef: ChangeDetectorRef
+ private cdRef: ChangeDetectorRef,
) {
state.hold(state.select(), () => this.cdRef.markForCheck());
}
}
```
+
+
+
+
+```typescript
+@Component({...})
+export class RxComponent {
+ readonly #state = rxState()
+ readonly #effects = rxEffects();
+ // Modifying the class
+ @HostBinding('[class.is-hidden]') get isHidden() {
+ return !this.#state.get().visible;
+ }
+ // Modifying styles
+ @HostBinding('[style.marginTop]') get marginTop() {
+ return `${this.#state.get().top}px`;
+ }
+ // Modifying styles
+ @HostBinding('[style.maxHeight]') get maxHeight() {
+ return `${this.#state.get().maxHeight}px`;
+ }
+
+ constructor(
+ private cdRef: ChangeDetectorRef
+ ) {
+ this.#effects.register(this.#state.select(), () => this.cdRef.markForCheck());
+ }
+}
+```
+
+
+
+
+
By calling `ChangeDetectorRef#markForCheck` after every state change, we flag our component dirty when needed and let angular's
`ChangeDetection` do it's magic for us.
@@ -126,6 +198,10 @@ We will utilize the `ElementRef` itself for this purpose and manipulate the DOM
Feel free to use angular's `Renderer2` if you want an abstraction layer, should work the exact same way.
+
+
+
+
```typescript
@Component({
providers: [RxState],
@@ -134,10 +210,40 @@ export class RxComponent {
constructor(
private state: RxState,
private elementRef: ElementRef,
- private cdRef: ChangeDetectorRef
+ private cdRef: ChangeDetectorRef,
) {
// optional: cdRef.detach();
this.state.hold(this.state.select(), ({ visible, top, maxHeight }) => {
+ const { nativeElement } = elementRef;
+ nativeElement.style.marginTop = `${top ? top : 0}px`;
+ nativeElement.style.maxHeight = `${maxHeight ? maxHeight : 100}px`;
+ // by using this, we could assign more classes
+ const classList: { [cls: string]: boolean } = {
+ 'is-hidden': !visible,
+ };
+ Object.keys(classList).forEach((cls) => {
+ classList[cls] ? nativeElement.classList.add(cls) : nativeElement.classList.remove(cls);
+ });
+ });
+ }
+}
+```
+
+
+
+
+
+```typescript
+@Component({...})
+export class RxComponent {
+ readonly #state = rxState()
+ readonly #effects = rxEffects();
+ constructor(
+ private elementRef: ElementRef,
+ private cdRef: ChangeDetectorRef
+ ) {
+ // optional: cdRef.detach();
+ this.#effects.register(this.#state.select(), ({ visible, top, maxHeight }) => {
const { nativeElement } = elementRef;
nativeElement.style.marginTop = `${top ? top : 0}px`;
nativeElement.style.maxHeight = `${maxHeight ? maxHeight : 100}px`;
@@ -154,3 +260,7 @@ export class RxComponent {
}
}
```
+
+
+
+
diff --git a/apps/docs/docs/state/tutorials/migrating-to-rxstate.md b/apps/docs/docs/state/tutorials/migrating-to-rxstate.mdx
similarity index 87%
rename from apps/docs/docs/state/tutorials/migrating-to-rxstate.md
rename to apps/docs/docs/state/tutorials/migrating-to-rxstate.mdx
index 9a801f354e..b08355a55f 100644
--- a/apps/docs/docs/state/tutorials/migrating-to-rxstate.md
+++ b/apps/docs/docs/state/tutorials/migrating-to-rxstate.mdx
@@ -3,6 +3,9 @@ title: Migrating to RxState
# Renamed from libs/state/docs/snippets/behavior-subject-vs-rx-state.md
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
# Migrating to RxState
Let's take a look at a simple checklist app, see how it can be implemented in an imperative way, and after that, we will iterate over it and add some reactiveness. We skip any additional logic such as routing, error handling etc., in these examples.
@@ -113,7 +116,7 @@ export class State {
select(path: K): Observable {
return this.data$.pipe(
- map((state) => state[path])
+ map((state) => state[path]),
// some additional logic
);
}
@@ -229,11 +232,7 @@ Now we need a place from which we can **trigger** this event. `@Input id: string
Also, we need to write a logic for getting our checklist from API and storing a response:
```ts
-initHandler$ = this.init$.pipe(
- switchMap((id) =>
- this.api.get(id).pipe(tap((checklist) => this.state.patch(checklist)))
- )
-);
+initHandler$ = this.init$.pipe(switchMap((id) => this.api.get(id).pipe(tap((checklist) => this.state.patch(checklist)))));
```
So far, so good. Inside `switchMap`, we are getting value passed to `init$` and switching to our API call. We
@@ -261,13 +260,7 @@ Answering logic
```ts
answerHandler$ = this.answer$.pipe(
withLatestFrom(this.tasks$),
- switchMap(([id, tasks]) =>
- this.api
- .answerTask(id)
- .pipe(
- tap(() => this.state.patch({ tasks: tasks.filter((t) => t.id !== id) }))
- )
- )
+ switchMap(([id, tasks]) => this.api.answerTask(id).pipe(tap(() => this.state.patch({ tasks: tasks.filter((t) => t.id !== id) })))),
);
```
@@ -308,31 +301,17 @@ export class ChecklistComponent implements OnDestroy {
init$ = new Subject();
answer$ = new Subject();
- initHandler$ = this.init$.pipe(
- switchMap((id) =>
- this.api.get(id).pipe(tap((checklist) => this.state.patch(checklist)))
- )
- );
+ initHandler$ = this.init$.pipe(switchMap((id) => this.api.get(id).pipe(tap((checklist) => this.state.patch(checklist)))));
answerHandler$ = this.answer$.pipe(
withLatestFrom(this.tasks$),
- switchMap(([id, tasks]) =>
- this.api
- .answerTask(id)
- .pipe(
- tap(() =>
- this.state.patch({ tasks: tasks.filter((t) => t.id !== id) })
- )
- )
- )
+ switchMap(([id, tasks]) => this.api.answerTask(id).pipe(tap(() => this.state.patch({ tasks: tasks.filter((t) => t.id !== id) })))),
);
private destroy$ = new Subject();
constructor(private api: TodoApiService) {
- merge(this.initHandler$, this.answerHandler$)
- .pipe(takeUntil(this.destroy$))
- .subscribe();
+ merge(this.initHandler$, this.answerHandler$).pipe(takeUntil(this.destroy$)).subscribe();
}
ngOnDestroy(): void {
@@ -410,9 +389,7 @@ Now we need to update our `answerHandler$` so it will return an id of task that
(api returns only status code). And connect it to our `tasks` property.
```ts
-answerHandler$ = this.answer$.pipe(
- switchMap((id) => this.api.answerTask(id).pipe(map(() => id)))
-);
+answerHandler$ = this.answer$.pipe(switchMap((id) => this.api.answerTask(id).pipe(map(() => id))));
```
```ts
@@ -430,6 +407,10 @@ our source. More on possible `connect` variants [here](../api/rx-state.md#connec
**Full component code**
+
+
+
+
```ts
export class ChecklistComponent {
@Input() set id(id: string) {
@@ -446,19 +427,52 @@ export class ChecklistComponent {
// HANDLERS
initHandler$ = this.init$.pipe(switchMap((id) => this.api.get(id)));
- answerHandler$ = this.answer$.pipe(
- switchMap((id) => this.api.answerTask(id).pipe(map(() => id)))
- );
+ answerHandler$ = this.answer$.pipe(switchMap((id) => this.api.answerTask(id).pipe(map(() => id))));
- constructor(private api: TodoApiService, private state: RxState) {
+ constructor(
+ private api: TodoApiService,
+ private state: RxState,
+ ) {
this.state.connect(this.initHandler$);
- this.state.connect('tasks', this.answerHandler$, (state, id) =>
- state.tasks.filter((t) => t.id !== id)
- );
+ this.state.connect('tasks', this.answerHandler$, (state, id) => state.tasks.filter((t) => t.id !== id));
}
}
```
+
+
+
+
+```ts
+export class ChecklistComponent {
+ readonly #state = rxState();
+ @Input() set id(id: string) {
+ this.init$.next(id);
+ }
+
+ // READS
+ name$ = this.#state.select('name');
+ tasks$ = this.#state.select('tasks');
+
+ // EVENTS
+ init$ = new Subject();
+ answer$ = new Subject();
+
+ // HANDLERS
+ initHandler$ = this.init$.pipe(switchMap((id) => this.api.get(id)));
+ answerHandler$ = this.answer$.pipe(switchMap((id) => this.api.answerTask(id).pipe(map(() => id))));
+
+ constructor(private api: TodoApiService) {
+ this.#state.connect(this.initHandler$);
+ this.#state.connect('tasks', this.answerHandler$, (state, id) => state.tasks.filter((t) => t.id !== id));
+ }
+}
+```
+
+
+
+
+
**Summary:**
- Both reading and writing are reactive.
diff --git a/apps/docs/docs/template/api/virtual-view-directive.mdx b/apps/docs/docs/template/api/virtual-view-directive.mdx
new file mode 100644
index 0000000000..90ac1a374b
--- /dev/null
+++ b/apps/docs/docs/template/api/virtual-view-directive.mdx
@@ -0,0 +1,184 @@
+---
+sidebar_label: 'RxVirtualView'
+sidebar_position: 7
+title: 'RxVirtualView'
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+:::info Developer preview
+
+This feature is under developer preview. It won't follow semver.
+
+:::
+
+## Motivation
+
+A large number of DOM elements can significantly impact performance, leading to slow initial load times and sluggish interactions.
+
+Especially mobile users have a very limited viewport available. Most of the pages contents are hidden below
+the fold. So why render them at all?
+
+When dealing with large lists or data sets there is a technique, known as virtual scrolling or windowing.
+It drastically improves the performance of your Angular applications.
+
+However, if you are not working with plain lists, or highly dynamic components, the concept of virtual scrolling isn't applicable.
+This is true for:
+
+- masonry layouts
+- dynamic grids
+- landing pages with widgets
+
+This is where the RxVirtualView directive comes in. It provides a simple way to only display the elements that are currently visible to
+the user.
+
+
+
+## Basic Usage
+
+RxVirtualView is designed to work in combination with related directives:
+
+- `rxVirtualViewObserver`: Defines the node being used for the `IntersectionObserver`. Provides cache & other services.
+- `rxVirtualView`: Defines the DOM node being observed for visibility.
+- `rxVirtualViewContent`: Defines the content shown when the observed node is visible.
+- `rxVirtualViewPlaceholder`: (Optional) Defines the placeholder shown when the observed node isn't visible.
+
+### Show a widget when it's visible, otherwise show a placeholder
+
+```html
+
+
+
+
+
+
+
+
+ Placeholder
+
+
+
+
+```
+
+This setup will:
+
+1. Use rxVirtualViewObserver to monitor the visibility of the rxVirtualView element.
+2. Render the content of rxVirtualViewContent when the element is visible.
+3. Show the rxVirtualViewPlaceholder when the element is not visible.
+
+:::tip Define placeholder dimensions
+
+The placeholder is what makes or breaks your experience with `RxVirtualView`. In best case it's just
+an empty container which has just the same dimensions as its content it should replace.
+
+This will make sure you don't run into stuttery scrolling behavior and layout shifts.
+
+:::
+
+### Optimize lists with @for
+
+This example demonstrates how to use RxVirtualView to optimize lists by only rendering the visible list items.
+We are only rendering the `item` component when it's visible to the user. Otherwise, it gets replaced by an empty div.
+
+```html
+
+ @for (item of items; track item.id) {
+
+
+
+
+ }
+
+```
+
+## Configuration & Inputs
+
+### RxVirtualViewObserver Inputs
+
+| Input | Type | description |
+| ------------ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `root` | ` ElementRef \ HTMLElement \ null` | The element where the IntersectionObserver is applied to. `null` referes to the browser viewport. See more [here](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#root) |
+| `rootMargin` | `string` | Margin around the root. See more [here](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#rootMargin) |
+| `threshold` | `number \ number[]` | Indicate at what percentage of the target's visibility the observer's callback should be executed. See more [here](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#threshold) |
+
+### RxVirtualView Inputs
+
+| Input | Type | description |
+| -------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `cacheEnabled` | `boolean` | Useful when we want to cache the contents and placeholders to optimize view rendering. |
+| `startWithPlaceholderAsap` | `boolean` | Whether to start with the placeholder asap or not. If `true`, the placeholder will be rendered immediately, without waiting for the content to be visible. This is useful when you want to render the placeholder immediately, but you don't want to wait for the content to be visible. This is to counter concurrent rendering, and to avoid flickering. |
+| `keepLastKnownSize` | `boolean` | This will keep the last known size of the host element while the content is visible. It sets 'minHeight' to the host node |
+| `useContentVisibility` | `boolean` | It will add the `content-visibility` CSS class to the host element, together with `contain-intrinsic-width` and `contain-intrinsic-height` CSS properties. |
+| `useContainment` | `boolean` | It will add `contain` css property with: - `size layout paint`: if `useContentVisibility` is `true` && placeholder is visible - `content`: if `useContentVisibility` is `false` or content is visible |
+| `placeholderStrategy` | `boolean` | The strategy to use for rendering the placeholder. Defaults to: `low` [Read more about strategies](../../cdk/render-strategies/strategies/concurrent-strategies) |
+| `contentStrategy` | `boolean` | The strategy to use for rendering the content. Defaults to: `normal` [Read more about strategies](../../cdk/render-strategies/strategies/concurrent-strategies) |
+
+### RxVirtualViewConfig
+
+Defines an interface representing all configuration that can be adjusted on provider level.
+
+```typescript
+export interface RxVirtualViewConfig {
+ keepLastKnownSize: boolean;
+ useContentVisibility: boolean;
+ useContainment: boolean;
+ placeholderStrategy: RxStrategyNames;
+ contentStrategy: RxStrategyNames;
+ cacheEnabled: boolean;
+ startWithPlaceholderAsap: boolean;
+ cache: {
+ /**
+ * The maximum number of contents that can be stored in the cache.
+ * Defaults to 20.
+ */
+ contentCacheSize: number;
+
+ /**
+ * The maximum number of placeholders that can be stored in the cache.
+ * Defaults to 20.
+ */
+ placeholderCacheSize: number;
+ };
+}
+```
+
+### Customize the config
+
+When you want to customize the default configuration on any provider level (e.g. component, appConfig, route, ...), you can use the `provideVirtualViewConfig` function.
+
+```typescript
+import { ApplicationConfig } from '@angular/core';
+import { provideVirtualViewConfig } from '@rx-angular/template/virtual-view';
+
+const appConfig: ApplicationConfig = {
+ providers: [
+ provideVirtualViewConfig({
+ /* your custom configuration */
+ }),
+ ],
+};
+```
+
+### Default configuration
+
+This is the default configuration which will be used when no other config was provided.
+
+```typescript
+
+{
+ keepLastKnownSize: false,
+ useContentVisibility: false,
+ useContainment: true,
+ placeholderStrategy: 'low',
+ contentStrategy: 'normal',
+ startWithPlaceholderAsap: false,
+ cacheEnabled: true,
+ cache: {
+ contentCacheSize: 20,
+ placeholderCacheSize: 20,
+ },
+};
+
+```
diff --git a/apps/docs/static/img/template/rx-virtual-view/rx-virtual-view.jpg b/apps/docs/static/img/template/rx-virtual-view/rx-virtual-view.jpg
new file mode 100644
index 0000000000..af0296d5f2
Binary files /dev/null and b/apps/docs/static/img/template/rx-virtual-view/rx-virtual-view.jpg differ
diff --git a/libs/cdk/CHANGELOG.md b/libs/cdk/CHANGELOG.md
index c5a301eee4..bbd03c3f50 100644
--- a/libs/cdk/CHANGELOG.md
+++ b/libs/cdk/CHANGELOG.md
@@ -2,6 +2,15 @@
This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
+## [19.0.1](https://github.com/rx-angular/rx-angular/compare/cdk@19.0.0...cdk@19.0.1) (2024-12-23)
+
+
+### Bug Fixes
+
+* replace toObservableMicrotask private API with proper solution ([339b2e3](https://github.com/rx-angular/rx-angular/commit/339b2e3e69e2ed49d368f33c45fa0bdaac8820f4))
+
+
+
# [19.0.0](https://github.com/rx-angular/rx-angular/compare/cdk@18.0.0...cdk@19.0.0) (2024-12-05)
diff --git a/libs/cdk/internals/core/src/index.ts b/libs/cdk/internals/core/src/index.ts
index c68d1236ad..8085dade1a 100644
--- a/libs/cdk/internals/core/src/index.ts
+++ b/libs/cdk/internals/core/src/index.ts
@@ -2,3 +2,4 @@ export { accumulateObservables } from './lib/accumulateObservables';
export { getZoneUnPatchedApi } from './lib/get-zone-unpatched-api';
export { ObservableAccumulation, ObservableMap } from './lib/model';
export { timeoutSwitchMapWith } from './lib/timeout';
+export { toObservableMicrotaskInternal } from './lib/toObservableMicrotask';
diff --git a/libs/cdk/internals/core/src/lib/toObservableMicrotask.ts b/libs/cdk/internals/core/src/lib/toObservableMicrotask.ts
new file mode 100644
index 0000000000..7d1490a389
--- /dev/null
+++ b/libs/cdk/internals/core/src/lib/toObservableMicrotask.ts
@@ -0,0 +1,47 @@
+import {
+ assertInInjectionContext,
+ DestroyRef,
+ effect,
+ inject,
+ Injector,
+ Signal,
+ untracked,
+} from '@angular/core';
+import { toObservable, ToObservableOptions } from '@angular/core/rxjs-interop';
+import { Observable, ReplaySubject } from 'rxjs';
+
+// Copied from angular/core/rxjs-interop/src/to_observable.ts -> because it's a private API
+// https://github.com/angular/angular/blob/46f00f951842dd117653df6cca3bfd5ee5baa0f1/packages/core/rxjs-interop/src/to_observable.ts#L72
+export function toObservableMicrotaskInternal(
+ source: Signal,
+ options?: ToObservableOptions,
+): Observable {
+ if (!options?.injector) {
+ assertInInjectionContext(toObservable);
+ }
+
+ const injector = options?.injector ?? inject(Injector);
+ const subject = new ReplaySubject(1);
+
+ const watcher = effect(
+ () => {
+ let value: T;
+ try {
+ value = source();
+ } catch (err) {
+ untracked(() => subject.error(err));
+ return;
+ }
+ untracked(() => subject.next(value));
+ },
+ // forceRoot will ensure that the effect will be scheduled as a microtask
+ { injector, manualCleanup: true, forceRoot: true },
+ );
+
+ injector.get(DestroyRef).onDestroy(() => {
+ watcher.destroy();
+ subject.complete();
+ });
+
+ return subject.asObservable();
+}
diff --git a/libs/cdk/package.json b/libs/cdk/package.json
index c8c33f973c..11171abbb7 100644
--- a/libs/cdk/package.json
+++ b/libs/cdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@rx-angular/cdk",
- "version": "19.0.0",
+ "version": "19.0.1",
"description": "@rx-angular/cdk is a Component Development Kit for ergonomic and highly performant angular applications. It helps to to build Large scale applications, UI libs, state management, rendering systems and much more. Furthermore the unique way of mixing reactive as well as imperative code leads to best DX and speed.",
"publishConfig": {
"access": "public"
diff --git a/libs/isr/CHANGELOG.md b/libs/isr/CHANGELOG.md
index 8261490e83..2e9667a214 100644
--- a/libs/isr/CHANGELOG.md
+++ b/libs/isr/CHANGELOG.md
@@ -2,6 +2,26 @@
This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
+# [19.0.0](https://github.com/rx-angular/rx-angular/compare/isr@18.1.0...isr@19.0.0) (2024-12-05)
+
+
+### Bug Fixes
+
+* **isr:** fix eslint issue ([08b814f](https://github.com/rx-angular/rx-angular/commit/08b814f323a22b94e2419e2c0146d1a88e9745ff))
+
+
+### Features
+
+* **isr:** add custom cache key generation logic ([821bd12](https://github.com/rx-angular/rx-angular/commit/821bd1202ef7ad7582a0013ad871f6e51a59a6e1))
+* **isr:** upgrade to ng-19 ([faa25ed](https://github.com/rx-angular/rx-angular/commit/faa25ed818f9d7b317dd1fdf17209a723042dc84))
+
+
+### BREAKING CHANGES
+
+* **isr:** bump peerDependency to angular 19
+
+
+
# [18.1.0](https://github.com/rx-angular/rx-angular/compare/isr@18.0.3...isr@18.1.0) (2024-09-04)
diff --git a/libs/isr/package.json b/libs/isr/package.json
index 6c296e03df..56dfc82ce0 100644
--- a/libs/isr/package.json
+++ b/libs/isr/package.json
@@ -2,7 +2,7 @@
"name": "@rx-angular/isr",
"author": "Enea Jahollari",
"description": "Incremental Static Regeneration for Angular",
- "version": "18.1.0",
+ "version": "19.0.0",
"peerDependencies": {
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0",
diff --git a/libs/state/CHANGELOG.md b/libs/state/CHANGELOG.md
index 40c5c24fc5..9f2516823f 100644
--- a/libs/state/CHANGELOG.md
+++ b/libs/state/CHANGELOG.md
@@ -2,6 +2,29 @@
This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
+## [19.0.1](https://github.com/rx-angular/rx-angular/compare/state@19.0.0...state@19.0.1) (2024-12-23)
+
+
+### Bug Fixes
+
+* replace toObservableMicrotask private API with proper solution ([339b2e3](https://github.com/rx-angular/rx-angular/commit/339b2e3e69e2ed49d368f33c45fa0bdaac8820f4))
+
+
+
+# [19.0.0](https://github.com/rx-angular/rx-angular/compare/state@18.1.0...state@19.0.0) (2024-12-05)
+
+
+### Features
+
+* **state:** upgrade to ng-19 ([cd6941e](https://github.com/rx-angular/rx-angular/commit/cd6941e23558ebd7c0d463703f2810a8889e6c36))
+
+
+### BREAKING CHANGES
+
+* **state:** bump peerDependency to angular 19
+
+
+
# [18.1.0](https://github.com/rx-angular/rx-angular/compare/state@18.0.0...state@18.1.0) (2024-10-03)
diff --git a/libs/state/package.json b/libs/state/package.json
index dcdd2bcf6f..a292fe8adf 100644
--- a/libs/state/package.json
+++ b/libs/state/package.json
@@ -1,6 +1,6 @@
{
"name": "@rx-angular/state",
- "version": "18.1.0",
+ "version": "19.0.1",
"description": "@rx-angular/state is a light-weight, flexible, strongly typed and tested tool dedicated to reduce the complexity of managing component state and side effects in angular",
"publishConfig": {
"access": "public"
diff --git a/libs/state/src/lib/rx-state.service.ts b/libs/state/src/lib/rx-state.service.ts
index 13eb21f62b..c3ee7ede2b 100644
--- a/libs/state/src/lib/rx-state.service.ts
+++ b/libs/state/src/lib/rx-state.service.ts
@@ -7,7 +7,8 @@ import {
OnDestroy,
Signal,
} from '@angular/core';
-import { ɵtoObservableMicrotask, toSignal } from '@angular/core/rxjs-interop';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { toObservableMicrotaskInternal } from '@rx-angular/cdk/internals/core';
import {
AccumulationFn,
createAccumulationObservable,
@@ -570,7 +571,9 @@ export class RxState
if (isSignal(keyOrInputOrSlice$) && !projectOrSlices$ && !projectValueFn) {
this.accumulator.nextSliceObservable(
- ɵtoObservableMicrotask(keyOrInputOrSlice$, { injector: this.injector }),
+ toObservableMicrotaskInternal(keyOrInputOrSlice$, {
+ injector: this.injector,
+ }),
);
return;
}
@@ -596,7 +599,7 @@ export class RxState
!projectValueFn
) {
const projectionStateFn = projectOrSlices$;
- const slice$ = ɵtoObservableMicrotask(keyOrInputOrSlice$, {
+ const slice$ = toObservableMicrotaskInternal(keyOrInputOrSlice$, {
injector: this.injector,
}).pipe(
map((v) => projectionStateFn(this.accumulator.state, v as Value)),
@@ -622,7 +625,7 @@ export class RxState
isSignal(projectOrSlices$) &&
!projectValueFn
) {
- const slice$ = ɵtoObservableMicrotask(projectOrSlices$, {
+ const slice$ = toObservableMicrotaskInternal(projectOrSlices$, {
injector: this.injector,
}).pipe(map((value) => ({ ...{}, [keyOrInputOrSlice$]: value })));
this.accumulator.nextSliceObservable(slice$);
@@ -653,7 +656,7 @@ export class RxState
isSignal(projectOrSlices$)
) {
const key: Key = keyOrInputOrSlice$;
- const slice$ = ɵtoObservableMicrotask(projectOrSlices$, {
+ const slice$ = toObservableMicrotaskInternal(projectOrSlices$, {
injector: this.injector,
}).pipe(
map((value) => ({
diff --git a/libs/template/.eslintrc.json b/libs/template/.eslintrc.json
index f87791bbce..2d1a9e8311 100644
--- a/libs/template/.eslintrc.json
+++ b/libs/template/.eslintrc.json
@@ -11,6 +11,7 @@
"rules": {
"@angular-eslint/directive-selector": "off",
"@angular-eslint/directive-class-suffix": "off",
+ "@angular-eslint/component-class-suffix": "off",
"@angular-eslint/component-selector": [
"error",
{
@@ -20,7 +21,7 @@
}
],
"@angular-eslint/no-input-rename": "off",
- "@typescript-eslint/no-non-null-assertion": "warn"
+ "@typescript-eslint/no-non-null-assertion": "off"
}
},
{
diff --git a/libs/template/CHANGELOG.md b/libs/template/CHANGELOG.md
index 348b075ca0..13065c508a 100644
--- a/libs/template/CHANGELOG.md
+++ b/libs/template/CHANGELOG.md
@@ -2,6 +2,19 @@
This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
+# [19.1.0](https://github.com/rx-angular/rx-angular/compare/template@19.0.0...template@19.1.0) (2024-12-23)
+
+
+### Bug Fixes
+
+* replace toObservableMicrotask private API with proper solution ([339b2e3](https://github.com/rx-angular/rx-angular/commit/339b2e3e69e2ed49d368f33c45fa0bdaac8820f4))
+
+
+### Features
+
+* **template:** introduce virtual-view subpackage ([0bfa4fe9](https://github.com/rx-angular/rx-angular/commit/0bfa4fe9b2e395d0df7a534f8277e37134f2d5ff))
+
+
# [19.0.0](https://github.com/rx-angular/rx-angular/compare/template@18.0.3...template@19.0.0) (2024-12-05)
diff --git a/libs/template/for/src/lib/for.directive.ts b/libs/template/for/src/lib/for.directive.ts
index 056d3b6e26..a778cb8195 100644
--- a/libs/template/for/src/lib/for.directive.ts
+++ b/libs/template/for/src/lib/for.directive.ts
@@ -18,11 +18,11 @@ import {
TrackByFunction,
ViewContainerRef,
} from '@angular/core';
-import { ɵtoObservableMicrotask } from '@angular/core/rxjs-interop';
import {
coerceDistinctWith,
coerceObservableWith,
} from '@rx-angular/cdk/coercing';
+import { toObservableMicrotaskInternal } from '@rx-angular/cdk/internals/core';
import {
RxStrategyNames,
RxStrategyProvider,
@@ -132,7 +132,7 @@ export class RxFor = NgIterable>
this.staticValue = undefined;
this.renderStatic = false;
this.observables$.next(
- ɵtoObservableMicrotask(potentialSignalOrObservable, {
+ toObservableMicrotaskInternal(potentialSignalOrObservable, {
injector: this.injector,
}),
);
diff --git a/libs/template/if/src/lib/if.directive.ts b/libs/template/if/src/lib/if.directive.ts
index 2872e89602..072f6cc2a1 100644
--- a/libs/template/if/src/lib/if.directive.ts
+++ b/libs/template/if/src/lib/if.directive.ts
@@ -14,8 +14,8 @@ import {
TemplateRef,
ViewContainerRef,
} from '@angular/core';
-import { ɵtoObservableMicrotask } from '@angular/core/rxjs-interop';
import { coerceAllFactory } from '@rx-angular/cdk/coercing';
+import { toObservableMicrotaskInternal } from '@rx-angular/cdk/internals/core';
import {
createTemplateNotifier,
RxNotificationKind,
@@ -558,7 +558,7 @@ export class RxIf
if (changes.rxIf) {
if (isSignal(this.rxIf)) {
this.templateNotifier.next(
- ɵtoObservableMicrotask(this.rxIf, { injector: this.injector }),
+ toObservableMicrotaskInternal(this.rxIf, { injector: this.injector }),
);
} else {
this.templateNotifier.next(this.rxIf);
diff --git a/libs/template/let/src/lib/let.directive.ts b/libs/template/let/src/lib/let.directive.ts
index 6d8a6ca978..0a64eb6c60 100644
--- a/libs/template/let/src/lib/let.directive.ts
+++ b/libs/template/let/src/lib/let.directive.ts
@@ -16,8 +16,8 @@ import {
TemplateRef,
ViewContainerRef,
} from '@angular/core';
-import { ɵtoObservableMicrotask } from '@angular/core/rxjs-interop';
import { coerceAllFactory } from '@rx-angular/cdk/coercing';
+import { toObservableMicrotaskInternal } from '@rx-angular/cdk/internals/core';
import {
createTemplateNotifier,
RxNotification,
@@ -590,7 +590,9 @@ export class RxLet implements OnInit, OnDestroy, OnChanges {
if (changes.rxLet) {
if (isSignal(this.rxLet)) {
this.observablesHandler.next(
- ɵtoObservableMicrotask(this.rxLet, { injector: this.injector }),
+ toObservableMicrotaskInternal(this.rxLet, {
+ injector: this.injector,
+ }),
);
} else {
this.observablesHandler.next(this.rxLet);
diff --git a/libs/template/package.json b/libs/template/package.json
index 705274c662..3fa4c2c318 100644
--- a/libs/template/package.json
+++ b/libs/template/package.json
@@ -1,6 +1,6 @@
{
"name": "@rx-angular/template",
- "version": "19.0.0",
+ "version": "19.1.0",
"description": "**Fully** Reactive Component Template Rendering in Angular. @rx-angular/template aims to be a reflection of Angular's built in renderings just reactive.",
"publishConfig": {
"access": "public"
diff --git a/libs/template/virtual-view/ng-package.json b/libs/template/virtual-view/ng-package.json
new file mode 100644
index 0000000000..d224a9f14a
--- /dev/null
+++ b/libs/template/virtual-view/ng-package.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
+ "lib": {
+ "entryFile": "src/index.ts",
+ "flatModuleFile": "template-virtual-view"
+ }
+}
diff --git a/libs/template/virtual-view/src/index.ts b/libs/template/virtual-view/src/index.ts
new file mode 100644
index 0000000000..11bfc12b94
--- /dev/null
+++ b/libs/template/virtual-view/src/index.ts
@@ -0,0 +1,4 @@
+export { RxVirtualView } from './lib/virtual-view.directive';
+export { RxVirtualViewContent } from './lib/virtual-view-content.directive';
+export { RxVirtualViewObserver } from './lib/virtual-view-observer.directive';
+export { RxVirtualViewPlaceholder } from './lib/virtual-view-placeholder.directive';
diff --git a/libs/template/virtual-view/src/lib/model.ts b/libs/template/virtual-view/src/lib/model.ts
new file mode 100644
index 0000000000..6f19c8fe2d
--- /dev/null
+++ b/libs/template/virtual-view/src/lib/model.ts
@@ -0,0 +1,38 @@
+import { TemplateRef, ViewContainerRef } from '@angular/core';
+import { Observable } from 'rxjs';
+
+/**
+ * @internal
+ */
+export interface _RxVirtualViewContent {
+ viewContainerRef: ViewContainerRef;
+ templateRef: TemplateRef;
+}
+
+/**
+ * @internal
+ */
+export interface _RxVirtualViewPlaceholder {
+ templateRef: TemplateRef;
+}
+
+/**
+ * @internal
+ */
+export abstract class _RxVirtualViewObserver {
+ abstract observeElementVisibility(
+ virtualView: HTMLElement,
+ ): Observable;
+ abstract observeElementSize(
+ element: Element,
+ options?: ResizeObserverOptions,
+ ): Observable;
+}
+
+/**
+ * @internal
+ */
+export abstract class _RxVirtualView {
+ abstract registerContent(content: _RxVirtualViewContent): void;
+ abstract registerPlaceholder(placeholder: _RxVirtualViewPlaceholder): void;
+}
diff --git a/libs/template/virtual-view/src/lib/resize-observer.ts b/libs/template/virtual-view/src/lib/resize-observer.ts
new file mode 100644
index 0000000000..62492319da
--- /dev/null
+++ b/libs/template/virtual-view/src/lib/resize-observer.ts
@@ -0,0 +1,46 @@
+import { DestroyRef, inject, Injectable } from '@angular/core';
+import { Observable, ReplaySubject, Subject } from 'rxjs';
+import { distinctUntilChanged, finalize } from 'rxjs/operators';
+
+/**
+ * A service that observes the resize of the elements.
+ *
+ * @developerPreview
+ */
+@Injectable()
+export class RxaResizeObserver {
+ #destroyRef = inject(DestroyRef);
+ #resizeObserver = new ResizeObserver((entries) => {
+ entries.forEach((entry) => {
+ if (this.#elements.has(entry.target))
+ this.#elements.get(entry.target)!.next(entry);
+ });
+ });
+
+ /** @internal */
+ #elements = new Map>();
+
+ constructor() {
+ this.#destroyRef.onDestroy(() => {
+ this.#elements.clear();
+ this.#resizeObserver.disconnect();
+ });
+ }
+
+ observeElement(
+ element: Element,
+ options?: ResizeObserverOptions,
+ ): Observable {
+ const resizeEvent$ = new ReplaySubject(1);
+ this.#elements.set(element, resizeEvent$);
+ this.#resizeObserver.observe(element, options);
+
+ return resizeEvent$.pipe(
+ distinctUntilChanged(),
+ finalize(() => {
+ this.#resizeObserver.unobserve(element);
+ this.#elements.delete(element);
+ }),
+ );
+ }
+}
diff --git a/libs/template/virtual-view/src/lib/tests/virtual-view.directive.spec.ts b/libs/template/virtual-view/src/lib/tests/virtual-view.directive.spec.ts
new file mode 100644
index 0000000000..17273c71e4
--- /dev/null
+++ b/libs/template/virtual-view/src/lib/tests/virtual-view.directive.spec.ts
@@ -0,0 +1,138 @@
+import { Component, input } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies';
+import { tap } from 'rxjs';
+import { provideVirtualViewConfig } from '../virtual-view.config';
+import { RxVirtualView } from '../virtual-view.directive';
+import { RxVirtualViewContent } from '../virtual-view-content.directive';
+import { RxVirtualViewObserver } from '../virtual-view-observer.directive';
+import { RxVirtualViewPlaceholder } from '../virtual-view-placeholder.directive';
+
+@Component({
+ template: `
+
+
+
ze-template
+ @if (withPlaceholder()) {
+
+ ze-placeholder
+
+ }
+
+
+ `,
+ standalone: true,
+ imports: [
+ RxVirtualViewObserver,
+ RxVirtualView,
+ RxVirtualViewPlaceholder,
+ RxVirtualViewContent,
+ ],
+})
+class VirtualViewTestComponent {
+ withPlaceholder = input(true);
+}
+
+class IntersectionObserverMock {
+ static cb: (entries: IntersectionObserverEntry[]) => void;
+
+ constructor(
+ public cb: (entries: IntersectionObserverEntry[]) => void,
+ init?: IntersectionObserverInit,
+ ) {
+ IntersectionObserverMock.cb = cb;
+ }
+
+ observe(element: Element) {}
+
+ unobserve(element: Element) {}
+
+ disconnect() {}
+}
+
+class ResizeObserverMock {
+ static cb: (entries: ResizeObserverEntry[]) => void;
+
+ constructor(public cb: (entries: ResizeObserverEntry[]) => void) {
+ ResizeObserverMock.cb = cb;
+ }
+
+ observe(element: Element, options?: ResizeObserverOptions) {}
+
+ unobserve(element: Element) {}
+
+ disconnect() {}
+}
+
+describe('RxVirtualView', () => {
+ let origIntersectionObserver;
+ let origResizeObserver;
+ let fixture: ComponentFixture;
+ beforeEach(() => {
+ origIntersectionObserver = window.IntersectionObserver;
+ origResizeObserver = window.ResizeObserver;
+ window.IntersectionObserver = IntersectionObserverMock as any;
+ window.ResizeObserver = ResizeObserverMock as any;
+
+ TestBed.configureTestingModule({
+ imports: [VirtualViewTestComponent],
+ providers: [
+ {
+ provide: RX_RENDER_STRATEGIES_CONFIG,
+ useValue: {
+ primaryStrategy: 'sync',
+ customStrategies: {
+ sync: {
+ name: 'sync',
+ work: (cdRef) => {
+ cdRef.detectChanges();
+ },
+ behavior:
+ ({ work }) =>
+ (o$) =>
+ o$.pipe(tap(() => work())),
+ },
+ },
+ },
+ },
+ provideVirtualViewConfig({
+ placeholderStrategy: 'sync',
+ contentStrategy: 'sync',
+ }),
+ ],
+ });
+ fixture = TestBed.createComponent(VirtualViewTestComponent);
+ });
+
+ afterEach(() => {
+ window.IntersectionObserver = origIntersectionObserver;
+ window.ResizeObserver = origResizeObserver;
+ });
+
+ it('should display template when visible', () => {
+ fixture.detectChanges();
+ const view = fixture.debugElement.query(By.css('.widget')).nativeElement;
+ IntersectionObserverMock.cb([
+ { isIntersecting: true, target: view } as any,
+ ]);
+ expect(view.textContent.trim()).toEqual('ze-template');
+ });
+ it('should display nothing when not visible and no placeholder', () => {
+ fixture.componentRef.setInput('withPlaceholder', false);
+ fixture.detectChanges();
+ const view = fixture.debugElement.query(By.css('.widget')).nativeElement;
+ IntersectionObserverMock.cb([
+ { isIntersecting: false, target: view } as any,
+ ]);
+ expect(view.textContent.trim()).toEqual('');
+ });
+ it('should display placeholder when not visible', () => {
+ fixture.detectChanges();
+ const view = fixture.debugElement.query(By.css('.widget')).nativeElement;
+ IntersectionObserverMock.cb([
+ { isIntersecting: false, target: view } as any,
+ ]);
+ expect(view.textContent.trim()).toEqual('ze-placeholder');
+ });
+});
diff --git a/libs/template/virtual-view/src/lib/virtual-view-cache.ts b/libs/template/virtual-view/src/lib/virtual-view-cache.ts
new file mode 100644
index 0000000000..3a20b6d515
--- /dev/null
+++ b/libs/template/virtual-view/src/lib/virtual-view-cache.ts
@@ -0,0 +1,116 @@
+import { inject, Injectable, OnDestroy, ViewRef } from '@angular/core';
+import { VIRTUAL_VIEW_CONFIG_TOKEN } from './virtual-view.config';
+
+/**
+ * A service that caches templates and placeholders to optimize view rendering.
+ * It makes sure that all cached resources are cleared when the service is destroyed.
+ *
+ * @developerPreview
+ */
+@Injectable()
+export class VirtualViewCache implements OnDestroy {
+ #config = inject(VIRTUAL_VIEW_CONFIG_TOKEN);
+
+ // Maximum number of content that can be stored in the cache.
+ #contentCacheSize = this.#config.cache.contentCacheSize;
+
+ // Cache for storing content views, identified by a unique key, which is the directive instance.
+ #contentCache = new Map();
+
+ // Maximum number of placeholders that can be stored in the cache.
+ #placeholderCacheSize = this.#config.cache.placeholderCacheSize;
+
+ // Cache for storing placeholder views, identified by a unique key.
+ #placeholderCache = new Map();
+
+ /**
+ * Stores a placeholder view in the cache. When the cache reaches its limit,
+ * the oldest entry is removed.
+ *
+ * @param key - The key used to identify the placeholder in the cache.
+ * @param view - The ViewRef of the placeholder to cache.
+ */
+ storePlaceholder(key: unknown, view: ViewRef) {
+ if (this.#placeholderCacheSize <= 0) {
+ view.destroy();
+ return;
+ }
+ if (this.#placeholderCache.size >= this.#placeholderCacheSize) {
+ this.#removeOldestEntry(this.#placeholderCache);
+ }
+ this.#placeholderCache.set(key, view);
+ }
+
+ /**
+ * Retrieves a cached placeholder view using the specified key.
+ *
+ * @param key - The key of the placeholder to retrieve.
+ * @returns The ViewRef of the cached placeholder, or undefined if not found.
+ */
+ getPlaceholder(key: unknown) {
+ const view = this.#placeholderCache.get(key);
+ this.#placeholderCache.delete(key);
+ return view;
+ }
+
+ /**
+ * Stores a content view in the cache. When the cache reaches its limit,
+ * the oldest entry is removed.
+ *
+ * @param key - The key used to identify the content in the cache.
+ * @param view - The ViewRef of the content to cache.
+ */
+ storeContent(key: unknown, view: ViewRef) {
+ if (this.#contentCacheSize <= 0) {
+ view.destroy();
+ return;
+ }
+ if (this.#contentCache.size >= this.#contentCacheSize) {
+ this.#removeOldestEntry(this.#contentCache);
+ }
+ this.#contentCache.set(key, view);
+ }
+
+ /**
+ * Retrieves a cached content view using the specified key.
+ *
+ * @param key - The key of the content to retrieve.
+ * @returns The ViewRef of the cached content, or undefined if not found.
+ */
+ getContent(key: unknown) {
+ const view = this.#contentCache.get(key);
+ this.#contentCache.delete(key);
+ return view;
+ }
+
+ /**
+ * Clears both content and placeholder caches for a given key.
+ *
+ * @param key - The key of the content and placeholder to remove.
+ */
+ clear(key: unknown) {
+ this.#contentCache.get(key)?.destroy();
+ this.#contentCache.delete(key);
+ this.#placeholderCache.get(key)?.destroy();
+ this.#placeholderCache.delete(key);
+ }
+
+ /**
+ * Clears all cached resources when the service is destroyed.
+ */
+ ngOnDestroy() {
+ this.#contentCache.forEach((view) => view.destroy());
+ this.#placeholderCache.forEach((view) => view.destroy());
+ this.#contentCache.clear();
+ this.#placeholderCache.clear();
+ }
+
+ #removeOldestEntry(cache: Map) {
+ const oldestValue = cache.entries().next().value;
+ if (oldestValue !== undefined) {
+ const [key, view] = oldestValue;
+ view?.destroy();
+ cache.delete(key);
+ }
+ }
+}
diff --git a/libs/template/virtual-view/src/lib/virtual-view-content.directive.ts b/libs/template/virtual-view/src/lib/virtual-view-content.directive.ts
new file mode 100644
index 0000000000..088c8f59d7
--- /dev/null
+++ b/libs/template/virtual-view/src/lib/virtual-view-content.directive.ts
@@ -0,0 +1,36 @@
+import {
+ Directive,
+ inject,
+ TemplateRef,
+ ViewContainerRef,
+} from '@angular/core';
+import { _RxVirtualViewContent } from './model';
+import { RxVirtualView } from './virtual-view.directive';
+
+/**
+ * The RxVirtualViewTemplate directive is a directive that allows you to create a content template for the virtual view.
+ *
+ * It can be used on an element/component to create a content template for the virtual view.
+ *
+ * It needs to be a sibling of the `rxVirtualView` directive.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
Virtual View 1
+ *
Loading...
+ *
+ *
+ * ```
+ *
+ * @developerPreview
+ */
+@Directive({ selector: '[rxVirtualViewContent]', standalone: true })
+export class RxVirtualViewContent implements _RxVirtualViewContent {
+ #virtualView = inject(RxVirtualView);
+ viewContainerRef = inject(ViewContainerRef);
+ constructor(public templateRef: TemplateRef) {
+ this.#virtualView.registerContent(this);
+ }
+}
diff --git a/libs/template/virtual-view/src/lib/virtual-view-observer.directive.ts b/libs/template/virtual-view/src/lib/virtual-view-observer.directive.ts
new file mode 100644
index 0000000000..de77ec39c6
--- /dev/null
+++ b/libs/template/virtual-view/src/lib/virtual-view-observer.directive.ts
@@ -0,0 +1,171 @@
+import {
+ computed,
+ Directive,
+ ElementRef,
+ inject,
+ input,
+ OnDestroy,
+ OnInit,
+} from '@angular/core';
+import {
+ BehaviorSubject,
+ combineLatest,
+ Observable,
+ ReplaySubject,
+ Subject,
+} from 'rxjs';
+import { distinctUntilChanged, finalize, map } from 'rxjs/operators';
+import { _RxVirtualViewObserver } from './model';
+import { RxaResizeObserver } from './resize-observer';
+import { VirtualViewCache } from './virtual-view-cache';
+
+/**
+ * The RxVirtualViewObserver directive observes the virtual view and emits a boolean value indicating whether the virtual view is visible.
+ * This is the container for the RxVirtualView directives.
+ *
+ * This is a mandatory directive for the RxVirtualView directives to work.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
Virtual View 1
+ *
Loading...
+ *
+ *
+ * ```
+ *
+ * @developerPreview
+ */
+@Directive({
+ selector: '[rxVirtualViewObserver]',
+ standalone: true,
+ providers: [
+ VirtualViewCache,
+ RxaResizeObserver,
+ { provide: _RxVirtualViewObserver, useExisting: RxVirtualViewObserver },
+ ],
+})
+export class RxVirtualViewObserver
+ extends _RxVirtualViewObserver
+ implements OnInit, OnDestroy
+{
+ #elementRef = inject>(ElementRef);
+
+ #observer: IntersectionObserver | null = null;
+
+ #resizeObserver = inject(RxaResizeObserver, { self: true });
+
+ /**
+ * The root element to observe.
+ *
+ * If not provided, the root element is the element that the directive is attached to.
+ */
+ root = input();
+
+ /**
+ * The root margin to observe.
+ *
+ * This is useful when you want to observe the virtual view in a specific area of the root element.
+ */
+ rootMargin = input('');
+
+ /**
+ * The threshold to observe.
+ *
+ * If you want to observe the virtual view when it is partially visible, you can set the threshold to a number between 0 and 1.
+ *
+ * For example, if you set the threshold to 0.5, the virtual view will be observed when it is half visible.
+ */
+ threshold = input(0);
+
+ #rootElement = computed(() => {
+ const root = this.root();
+ if (root) {
+ if (root instanceof ElementRef) {
+ return root.nativeElement;
+ }
+ return root;
+ } else if (root === null) {
+ return null;
+ }
+ return this.#elementRef.nativeElement;
+ });
+
+ #elements = new Map>();
+
+ #forcedHidden$ = new BehaviorSubject(false);
+
+ ngOnInit(): void {
+ this.#observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (this.#elements.has(entry.target))
+ this.#elements.get(entry.target)?.next(entry.isIntersecting);
+ });
+ },
+ {
+ root: this.#rootElement(),
+ rootMargin: this.rootMargin(),
+ threshold: this.threshold(),
+ },
+ );
+ }
+
+ ngOnDestroy() {
+ this.#elements.clear();
+ this.#observer?.disconnect();
+ this.#observer = null;
+ }
+
+ /**
+ * Hide all the virtual views.
+ *
+ * This is useful when you want to hide all the virtual views when the user cannot see them.
+ *
+ * For example, when the user opens a modal, you can hide all the virtual views to improve performance.
+ *
+ * **IMPORTANT:**
+ *
+ * Don't forget to call `showAllVisible()` when you want to show the virtual views again.
+ */
+ hideAll(): void {
+ this.#forcedHidden$.next(true);
+ }
+
+ /**
+ * Show all the virtual views that are currently visible.
+ *
+ * This needs to be called if `hideAll()` was called before.
+ */
+ showAllVisible(): void {
+ this.#forcedHidden$.next(false);
+ }
+
+ observeElementVisibility(virtualView: HTMLElement) {
+ const isVisible$ = new ReplaySubject(1);
+
+ // Store the view and the visibility state in the map.
+ // This allows us to retrieve the visibility state later.
+ this.#elements.set(virtualView, isVisible$);
+
+ // Start observing the virtual view immediately.
+ this.#observer?.observe(virtualView);
+
+ return combineLatest([isVisible$, this.#forcedHidden$]).pipe(
+ map(([isVisible, forcedHidden]) => (forcedHidden ? false : isVisible)),
+ distinctUntilChanged(),
+ finalize(() => {
+ this.#observer?.unobserve(virtualView);
+ this.#elements.delete(virtualView);
+ }),
+ );
+ }
+
+ observeElementSize(
+ element: Element,
+ options?: ResizeObserverOptions,
+ ): Observable {
+ return this.#resizeObserver.observeElement(element, options);
+ }
+}
diff --git a/libs/template/virtual-view/src/lib/virtual-view-placeholder.directive.ts b/libs/template/virtual-view/src/lib/virtual-view-placeholder.directive.ts
new file mode 100644
index 0000000000..c6cefb3673
--- /dev/null
+++ b/libs/template/virtual-view/src/lib/virtual-view-placeholder.directive.ts
@@ -0,0 +1,29 @@
+import { Directive, inject, TemplateRef } from '@angular/core';
+import { _RxVirtualView, _RxVirtualViewPlaceholder } from './model';
+
+/**
+ * The RxVirtualViewPlaceholder directive is a directive that allows you to create a placeholder for the virtual view.
+ *
+ * It can be used on an element/component to create a placeholder for the virtual view.
+ *
+ * It needs to be a sibling of the `rxVirtualView` directive.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
Virtual View 1
+ *
Loading...
+ *
+ *
+ * ```
+ *
+ * @developerPreview
+ */
+@Directive({ selector: '[rxVirtualViewPlaceholder]', standalone: true })
+export class RxVirtualViewPlaceholder implements _RxVirtualViewPlaceholder {
+ #virtualView = inject(_RxVirtualView);
+ constructor(public templateRef: TemplateRef) {
+ this.#virtualView.registerPlaceholder(this);
+ }
+}
diff --git a/libs/template/virtual-view/src/lib/virtual-view.config.ts b/libs/template/virtual-view/src/lib/virtual-view.config.ts
new file mode 100644
index 0000000000..358b36b3f7
--- /dev/null
+++ b/libs/template/virtual-view/src/lib/virtual-view.config.ts
@@ -0,0 +1,89 @@
+import { InjectionToken, Provider } from '@angular/core';
+import { RxStrategyNames } from '@rx-angular/cdk/render-strategies';
+
+export const VIRTUAL_VIEW_CONFIG_TOKEN =
+ new InjectionToken('VIRTUAL_VIEW_CONFIG_TOKEN', {
+ providedIn: 'root',
+ factory: () => VIRTUAL_VIEW_CONFIG_DEFAULT,
+ });
+
+export interface RxVirtualViewConfig {
+ keepLastKnownSize: boolean;
+ useContentVisibility: boolean;
+ useContainment: boolean;
+ placeholderStrategy: RxStrategyNames;
+ contentStrategy: RxStrategyNames;
+ cacheEnabled: boolean;
+ startWithPlaceholderAsap: boolean;
+ cache: {
+ /**
+ * The maximum number of contents that can be stored in the cache.
+ * Defaults to 20.
+ */
+ contentCacheSize: number;
+
+ /**
+ * The maximum number of placeholders that can be stored in the cache.
+ * Defaults to 20.
+ */
+ placeholderCacheSize: number;
+ };
+}
+
+export const VIRTUAL_VIEW_CONFIG_DEFAULT: RxVirtualViewConfig = {
+ keepLastKnownSize: false,
+ useContentVisibility: false,
+ useContainment: true,
+ placeholderStrategy: 'low',
+ contentStrategy: 'normal',
+ startWithPlaceholderAsap: false,
+ cacheEnabled: true,
+ cache: {
+ contentCacheSize: 20,
+ placeholderCacheSize: 20,
+ },
+};
+
+/**
+ * Provides a configuration object for the `VirtualView` service.
+ *
+ * Can be used to customize the behavior of the `VirtualView` service.
+ *
+ * Default configuration:
+ * - contentCacheSize: 20
+ * - placeholderCacheSize: 20
+ *
+ * Example usage:
+ *
+ * ```ts
+ * import { provideVirtualViewConfig } from '@rx-angular/template/virtual-view';
+ *
+ * const appConfig: ApplicationConfig = {
+ * providers: [
+ * provideVirtualViewConfig({
+ * contentCacheSize: 50,
+ * placeholderCacheSize: 50,
+ * }),
+ * ],
+ * };
+ * ```
+ *
+ * @developerPreview
+ *
+ * @param config - The configuration object.
+ * @returns An object that can be provided to the `VirtualView` service.
+ */
+export function provideVirtualViewConfig(
+ config: Partial<
+ RxVirtualViewConfig & { cache?: Partial }
+ >,
+): Provider {
+ return {
+ provide: VIRTUAL_VIEW_CONFIG_TOKEN,
+ useValue: {
+ ...VIRTUAL_VIEW_CONFIG_DEFAULT,
+ ...config,
+ cache: { ...VIRTUAL_VIEW_CONFIG_DEFAULT.cache, ...(config?.cache ?? {}) },
+ },
+ } satisfies Provider;
+}
diff --git a/libs/template/virtual-view/src/lib/virtual-view.directive.ts b/libs/template/virtual-view/src/lib/virtual-view.directive.ts
new file mode 100644
index 0000000000..3f8750b6bb
--- /dev/null
+++ b/libs/template/virtual-view/src/lib/virtual-view.directive.ts
@@ -0,0 +1,361 @@
+import {
+ AfterContentInit,
+ booleanAttribute,
+ computed,
+ DestroyRef,
+ Directive,
+ ElementRef,
+ EmbeddedViewRef,
+ inject,
+ input,
+ OnDestroy,
+ signal,
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import {
+ RxStrategyNames,
+ RxStrategyProvider,
+} from '@rx-angular/cdk/render-strategies';
+import { NEVER, Observable, ReplaySubject } from 'rxjs';
+import {
+ distinctUntilChanged,
+ finalize,
+ map,
+ switchMap,
+ tap,
+} from 'rxjs/operators';
+import {
+ _RxVirtualView,
+ _RxVirtualViewContent,
+ _RxVirtualViewObserver,
+ _RxVirtualViewPlaceholder,
+} from './model';
+import { VIRTUAL_VIEW_CONFIG_TOKEN } from './virtual-view.config';
+import { VirtualViewCache } from './virtual-view-cache';
+
+/**
+ * The RxVirtualView directive is a directive that allows you to create virtual views.
+ *
+ * It can be used on an element/component to create a virtual view.
+ *
+ * It works by using 3 directives:
+ * - `rxVirtualViewContent`: The content to render when the virtual view is visible.
+ * - `rxVirtualViewPlaceholder`: The placeholder to render when the virtual view is not visible.
+ * - `rxVirtualViewObserver`: The directive that observes the virtual view and emits a boolean value indicating whether the virtual view is visible.
+ *
+ * The `rxVirtualViewObserver` directive is mandatory for the `rxVirtualView` directive to work.
+ * And it needs to be a sibling of the `rxVirtualView` directive.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
Virtual View 1
+ *
Loading...
+ *
+ *
+ * ```
+ *
+ * @developerPreview
+ */
+@Directive({
+ selector: '[rxVirtualView]',
+ host: {
+ '[style.--rx-vw-h]': 'height()',
+ '[style.--rx-vw-w]': 'width()',
+ '[style.min-height]': 'minHeight()',
+ '[style.min-width]': 'minWidth()',
+ '[style.contain]': 'containment()',
+ '[style.contain-intrinsic-width]': 'intrinsicWidth()',
+ '[style.contain-intrinsic-height]': 'intrinsicHeight()',
+ '[style.content-visibility]': 'useContentVisibility() ? "auto" : null',
+ },
+ providers: [{ provide: _RxVirtualView, useExisting: RxVirtualView }],
+})
+export class RxVirtualView
+ implements AfterContentInit, _RxVirtualView, OnDestroy
+{
+ readonly #observer = inject(_RxVirtualViewObserver, { optional: true });
+ readonly #elementRef = inject>(ElementRef);
+ readonly #strategyProvider = inject(RxStrategyProvider);
+ readonly #viewCache = inject(VirtualViewCache, { optional: true });
+ readonly #destroyRef = inject(DestroyRef);
+ readonly #config = inject(VIRTUAL_VIEW_CONFIG_TOKEN);
+
+ #content: _RxVirtualViewContent | null = null;
+ #placeholder: _RxVirtualViewPlaceholder | null = null;
+
+ /**
+ * Useful when we want to cache the templates and placeholders to optimize view rendering.
+ *
+ * Enabled by default.
+ */
+ readonly cacheEnabled = input(this.#config.cacheEnabled, {
+ transform: booleanAttribute,
+ });
+
+ /**
+ * Whether to start with the placeholder asap or not.
+ *
+ * If `true`, the placeholder will be rendered immediately, without waiting for the content to be visible.
+ * This is useful when you want to render the placeholder immediately, but you don't want to wait for the content to be visible.
+ *
+ * This is to counter concurrent rendering, and to avoid flickering.
+ */
+ readonly startWithPlaceholderAsap = input(
+ this.#config.startWithPlaceholderAsap,
+ {
+ transform: booleanAttribute,
+ },
+ );
+
+ /**
+ * This will keep the last known size of the host element while the content is visible.
+ */
+ readonly keepLastKnownSize = input(this.#config.keepLastKnownSize, {
+ transform: booleanAttribute,
+ });
+
+ /**
+ * Whether to use content visibility or not.
+ *
+ * It will add the `content-visibility` CSS class to the host element, together with
+ * `contain-intrinsic-width` and `contain-intrinsic-height` CSS properties.
+ */
+ readonly useContentVisibility = input(this.#config.useContentVisibility, {
+ transform: booleanAttribute,
+ });
+
+ /**
+ * Whether to use containment or not.
+ *
+ * It will add `contain` css property with:
+ * - `size layout paint`: if `useContentVisibility` is `true` && placeholder is visible
+ * - `content`: if `useContentVisibility` is `false` || content is visible
+ */
+ readonly useContainment = input(this.#config.useContainment, {
+ transform: booleanAttribute,
+ });
+
+ /**
+ * The strategy to use for rendering the placeholder.
+ */
+ readonly placeholderStrategy = input>(
+ this.#config.placeholderStrategy,
+ );
+
+ /**
+ * The strategy to use for rendering the content.
+ */
+ readonly contentStrategy = input>(
+ this.#config.contentStrategy,
+ );
+
+ /**
+ * A function extracting width & height from a ResizeObserverEntry
+ */
+ readonly extractSize =
+ input<(entry: ResizeObserverEntry) => { width: number; height: number }>(
+ defaultExtractSize,
+ );
+
+ /**
+ * ResizeObserverOptions
+ */
+ readonly resizeObserverOptions = input();
+
+ readonly #placeholderVisible = signal(false);
+
+ #contentIsShown = false;
+
+ readonly #visible$ = new ReplaySubject(1);
+
+ readonly size = signal({ width: 0, height: 0 });
+
+ readonly width = computed(() =>
+ this.size().width ? `${this.size().width}px` : 'auto',
+ );
+
+ readonly height = computed(() =>
+ this.size().height ? `${this.size().height}px` : 'auto',
+ );
+
+ readonly containment = computed(() => {
+ if (!this.useContainment()) {
+ return null;
+ }
+ return this.useContentVisibility() && this.#placeholderVisible()
+ ? 'size layout paint'
+ : 'content';
+ });
+
+ readonly intrinsicWidth = computed(() => {
+ if (!this.useContentVisibility()) {
+ return null;
+ }
+ return this.width() === 'auto' ? 'auto' : `auto ${this.width()}`;
+ });
+ readonly intrinsicHeight = computed(() => {
+ if (!this.useContentVisibility()) {
+ return null;
+ }
+ return this.height() === 'auto' ? 'auto' : `auto ${this.height()}`;
+ });
+
+ readonly minHeight = computed(() => {
+ return this.keepLastKnownSize() && this.#placeholderVisible()
+ ? this.height()
+ : null;
+ });
+ readonly minWidth = computed(() => {
+ return this.keepLastKnownSize() && this.#placeholderVisible()
+ ? this.width()
+ : null;
+ });
+
+ constructor() {
+ if (!this.#observer) {
+ throw new Error(
+ 'RxVirtualView expects you to provide a RxVirtualViewObserver',
+ );
+ }
+ }
+
+ ngAfterContentInit() {
+ if (!this.#content) {
+ throw new Error(
+ 'RxVirtualView expects you to provide a RxVirtualViewContent',
+ );
+ }
+ if (this.startWithPlaceholderAsap()) {
+ this.renderPlaceholder();
+ }
+
+ this.#observer
+ ?.observeElementVisibility(this.#elementRef.nativeElement)
+ .pipe(takeUntilDestroyed(this.#destroyRef))
+ .subscribe((visible) => this.#visible$.next(visible));
+
+ this.#visible$
+ .pipe(
+ distinctUntilChanged(),
+ switchMap((visible) => {
+ if (visible) {
+ return this.#contentIsShown
+ ? NEVER
+ : this.showContent$().pipe(
+ switchMap((view) => {
+ const resize$ = this.#observer!.observeElementSize(
+ this.#elementRef.nativeElement,
+ this.resizeObserverOptions(),
+ );
+ view.detectChanges();
+ return resize$;
+ }),
+ map(this.extractSize()),
+ tap(({ width, height }) => this.size.set({ width, height })),
+ );
+ }
+ return this.#placeholderVisible() ? NEVER : this.showPlaceholder$();
+ }),
+ finalize(() => {
+ this.#viewCache!.clear(this);
+ }),
+ takeUntilDestroyed(this.#destroyRef),
+ )
+ .subscribe();
+ }
+
+ ngOnDestroy() {
+ this.#content = null;
+ this.#placeholder = null;
+ }
+
+ registerContent(content: _RxVirtualViewContent) {
+ this.#content = content;
+ }
+
+ registerPlaceholder(placeholder: _RxVirtualViewPlaceholder) {
+ this.#placeholder = placeholder;
+ }
+
+ /**
+ * Shows the content using the configured rendering strategy (by default: normal).
+ * @private
+ */
+ private showContent$(): Observable> {
+ return this.#strategyProvider.schedule(
+ () => {
+ this.#contentIsShown = true;
+ this.#placeholderVisible.set(false);
+ const placeHolder = this.#content!.viewContainerRef.detach();
+ if (this.cacheEnabled() && placeHolder) {
+ this.#viewCache!.storePlaceholder(this, placeHolder);
+ } else if (!this.cacheEnabled() && placeHolder) {
+ placeHolder.destroy();
+ }
+ const tmpl =
+ (this.#viewCache!.getContent(this) as EmbeddedViewRef) ??
+ this.#content!.templateRef.createEmbeddedView({});
+ this.#content!.viewContainerRef.insert(tmpl);
+ placeHolder?.detectChanges();
+
+ return tmpl;
+ },
+ { scope: this, strategy: this.contentStrategy() },
+ );
+ }
+
+ /**
+ * Shows the placeholder using the configured rendering strategy (by default: low).
+ * @private
+ */
+ private showPlaceholder$() {
+ return this.#strategyProvider.schedule(() => this.renderPlaceholder(), {
+ scope: this,
+ strategy: this.placeholderStrategy(),
+ });
+ }
+
+ /**
+ * Renders a placeholder within the view container, and hides the content.
+ *
+ * If we already have a content and cache enabled, we store the content in
+ * the cache, so we can reuse it later.
+ *
+ * When we want to render the placeholder, we try to get it from the cache,
+ * and if it is not available, we create a new one.
+ *
+ * Then insert the placeholder into the view container and trigger a CD.
+ */
+ private renderPlaceholder() {
+ this.#placeholderVisible.set(true);
+ this.#contentIsShown = false;
+
+ const content = this.#content!.viewContainerRef.detach();
+
+ if (content) {
+ if (this.cacheEnabled()) {
+ this.#viewCache!.storeContent(this, content);
+ } else {
+ content.destroy();
+ }
+
+ content?.detectChanges();
+ }
+
+ if (this.#placeholder) {
+ const placeholderRef =
+ this.#viewCache!.getPlaceholder(this) ??
+ this.#placeholder.templateRef.createEmbeddedView({});
+
+ this.#content!.viewContainerRef.insert(placeholderRef);
+ placeholderRef.detectChanges();
+ }
+ }
+}
+
+const defaultExtractSize = (entry: ResizeObserverEntry) => ({
+ width: entry.borderBoxSize[0].inlineSize,
+ height: entry.borderBoxSize[0].blockSize,
+});
diff --git a/tsconfig.base.json b/tsconfig.base.json
index ae7fad2cf1..358ea747bd 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -59,6 +59,9 @@
"@rx-angular/template/let": ["libs/template/let/src/index.ts"],
"@rx-angular/template/push": ["libs/template/push/src/index.ts"],
"@rx-angular/template/unpatch": ["libs/template/unpatch/src/index.ts"],
+ "@rx-angular/template/virtual-view": [
+ "libs/template/virtual-view/src/index.ts"
+ ],
"@test-helpers/rx-angular": ["libs/test-helpers/src/index.ts"]
}
},