@lankajs/angular 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/chunk-5WRI5ZAA.js +31 -0
- package/dist/chunk-5WRI5ZAA.js.map +1 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.d.ts +46 -0
- package/dist/testing.js +39293 -0
- package/dist/testing.js.map +1 -0
- package/package.json +58 -0
- package/skills/lanka-angular/SKILL.md +202 -0
- package/skills/lanka-angular/reference.md +277 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lankajs/angular",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "module: One function — `useLankaVM` — over a signal, and the access tracking core already does.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/lankajs/lanka.git",
|
|
10
|
+
"directory": "modules/bindings/angular"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/lankajs/lanka/tree/main/modules/bindings/angular#readme",
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./testing": {
|
|
21
|
+
"types": "./dist/testing.d.ts",
|
|
22
|
+
"default": "./dist/testing.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md",
|
|
30
|
+
"skills"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"lanka": "^2.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@angular/common": "^20.3.31",
|
|
37
|
+
"@angular/core": "^20.3.31",
|
|
38
|
+
"@testing-library/angular": "^17.4.0",
|
|
39
|
+
"@lankajs/tool-testing": "^2.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@angular/core": "^20.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@testing-library/angular": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"lint": "eslint src _playground --max-warnings=0",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"test:coverage": "vitest run --coverage",
|
|
54
|
+
"test:watch": "vitest",
|
|
55
|
+
"bench": "vitest bench --run",
|
|
56
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lanka-angular
|
|
3
|
+
description: Read a lanka ViewModel from an Angular component with useLankaVM, split it into a signal per field with toLankaSignals, or hand it to the async pipe and RxJS with toLankaObservable. Use when writing or reviewing an Angular or Analog screen in a lanka application, when "must be called in an injection context" appears, when a template does not update after state changed, when a service or interceptor needs ViewModel state, or when reviewing code that imports `@lankajs/angular`.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: lankajs
|
|
7
|
+
package: @lankajs/angular
|
|
8
|
+
version: "0.1.0"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# @lankajs/angular
|
|
12
|
+
|
|
13
|
+
One call to read a ViewModel, two Angular-shaped spellings, and one refusal the
|
|
14
|
+
other four bindings do not make. `reference.md` beside this file is the full
|
|
15
|
+
guide.
|
|
16
|
+
|
|
17
|
+
> [!NOTE]
|
|
18
|
+
> Only what the framework or a gate refuses is binding. Everything else here is a
|
|
19
|
+
> recommendation you can adapt.
|
|
20
|
+
|
|
21
|
+
## Pick the call
|
|
22
|
+
|
|
23
|
+
| The situation | Use |
|
|
24
|
+
| --------------------------------------------------- | ------------------------------------------------- |
|
|
25
|
+
| a component reads a ViewModel | `useLankaVM(todoVM)` — one `Signal` |
|
|
26
|
+
| it needs one derived value | `useLankaVM(todoVM, (s) => s.rows.length)` |
|
|
27
|
+
| a template reads `rows()` per field, like a service | `toLankaSignals(todoVM)` |
|
|
28
|
+
| the `async` pipe, `combineLatest`, an interceptor | `toLankaObservable(todoVM)` |
|
|
29
|
+
| outside an injection context — a handler, a module | `todoVM.getState()` |
|
|
30
|
+
| a component test | `renderWithLanka` from `@lankajs/angular/testing` |
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { Component } from "@angular/core";
|
|
34
|
+
import { useLankaVM } from "@lankajs/angular";
|
|
35
|
+
import { todoVM } from "./todoVM";
|
|
36
|
+
|
|
37
|
+
@Component({
|
|
38
|
+
standalone: true,
|
|
39
|
+
template: `
|
|
40
|
+
@if (state().isLoading) {
|
|
41
|
+
<p>loading</p>
|
|
42
|
+
}
|
|
43
|
+
@for (row of state().rows; track row) {
|
|
44
|
+
<li>{{ row }}</li>
|
|
45
|
+
}
|
|
46
|
+
`,
|
|
47
|
+
})
|
|
48
|
+
export class TodoScreen {
|
|
49
|
+
protected readonly state = useLankaVM(todoVM);
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It answers a **`Signal`** — Angular's own idea of reactivity, which is the one
|
|
54
|
+
thing the shelf does not make uniform. **Zoneless needs no extra step**: a signal
|
|
55
|
+
is what zoneless change detection reads. With zones it works unchanged.
|
|
56
|
+
|
|
57
|
+
## A signal per field
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { toLankaSignals } from "@lankajs/angular";
|
|
61
|
+
|
|
62
|
+
@Component({
|
|
63
|
+
template: `
|
|
64
|
+
@if (todos.isLoading()) {
|
|
65
|
+
<p>loading</p>
|
|
66
|
+
}
|
|
67
|
+
@for (row of todos.rows(); track row) {
|
|
68
|
+
<li>{{ row }}</li>
|
|
69
|
+
}
|
|
70
|
+
`,
|
|
71
|
+
})
|
|
72
|
+
export class TodoScreen {
|
|
73
|
+
protected readonly todos = toLankaSignals(todoVM);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Actions come through as plain functions — `todos.load()` — because an action is
|
|
78
|
+
one object for the life of the ViewModel and a signal would make every call site
|
|
79
|
+
write `load()()`. There is ONE subscription behind the whole set and each field is
|
|
80
|
+
a `computed` over it, so Angular's own deduplication does the rest.
|
|
81
|
+
|
|
82
|
+
The field list is read once, at the call. A ViewModel declares its state up
|
|
83
|
+
front, so that is the whole of it — `useLankaVM` is the answer for a state whose
|
|
84
|
+
shape is genuinely dynamic.
|
|
85
|
+
|
|
86
|
+
## A stream, for the RxJS half
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { toLankaObservable } from "@lankajs/angular";
|
|
90
|
+
|
|
91
|
+
@Component({
|
|
92
|
+
template: `@if (todos$ | async; as todos) {
|
|
93
|
+
<p>{{ todos.rows.length }}</p>
|
|
94
|
+
}`,
|
|
95
|
+
})
|
|
96
|
+
export class TodoScreen {
|
|
97
|
+
protected readonly todos$ = toLankaObservable(todoVM);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
It emits the CURRENT state first, like a `BehaviorSubject`, so `| async` shows
|
|
102
|
+
something on the first pass, and each subscriber gets its own recording.
|
|
103
|
+
|
|
104
|
+
**It needs no injection context**, unlike the two signal spellings: a subscriber
|
|
105
|
+
holds its own unsubscribe, which is what `DestroyRef` answers for a signal. So it
|
|
106
|
+
works in a service, a resolver, an interceptor and a plain function.
|
|
107
|
+
|
|
108
|
+
**It imports no `rxjs`.** `AsyncPipe` accepts `Subscribable<T>`, so this
|
|
109
|
+
satisfies the contract structurally. Pipe it when you want operators:
|
|
110
|
+
`from(toLankaObservable(todoVM))`, or `toObservable` from
|
|
111
|
+
`@angular/core/rxjs-interop` over the signal `useLankaVM` answers.
|
|
112
|
+
|
|
113
|
+
## It must be called in an injection context
|
|
114
|
+
|
|
115
|
+
A constructor, a field initialiser, a factory, or inside
|
|
116
|
+
`runInInjectionContext`. `useLankaVM` and `toLankaSignals` assert it and the
|
|
117
|
+
message names the fix.
|
|
118
|
+
|
|
119
|
+
Stricter than `@lankajs/vue` and `@lankajs/solid`, which publish a `stop()` for a
|
|
120
|
+
call outside their framework's scope. Angular cannot: `DestroyRef` is the only
|
|
121
|
+
way to learn the caller has gone, and a subscription that cannot learn that is a
|
|
122
|
+
leak with no owner. A refusal you read once beats a leak found in production.
|
|
123
|
+
|
|
124
|
+
## What updates
|
|
125
|
+
|
|
126
|
+
Without a selector the signal carries a value that RECORDS which keys you read,
|
|
127
|
+
and the next change updates it only if one of those moved — so a component
|
|
128
|
+
reading `rows` does not repaint because a spinner elsewhere turned off. With a
|
|
129
|
+
selector, the selector decides and tracking is bypassed.
|
|
130
|
+
|
|
131
|
+
> [!WARNING]
|
|
132
|
+
> **The blind spot.** Tracking sees keys you read DIRECTLY. A key reached only
|
|
133
|
+
> inside a derived getter is invisible to it, so a change to that key updates
|
|
134
|
+
> nothing and the screen freezes with no error. Set
|
|
135
|
+
> `enableAccessTrackingOptimization: false` on such a ViewModel. Do NOT read the
|
|
136
|
+
> underlying keys in the template "for the side effect": that is dead code, and a
|
|
137
|
+
> refactor or a lint autofix removes it. In development the framework announces
|
|
138
|
+
> the mismatch by ViewModel and key name.
|
|
139
|
+
|
|
140
|
+
## Testing
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { renderWithLanka } from "@lankajs/angular/testing";
|
|
144
|
+
|
|
145
|
+
await renderWithLanka(TodoScreen, {
|
|
146
|
+
fakes: { gateways: { TodoGateway: { list: () => Promise.resolve([]) } } },
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
It is `await`ed where the other four bindings' are not: Angular Testing Library
|
|
151
|
+
drives `TestBed`, which COMPILES a component rather than merely mounting one.
|
|
152
|
+
Every call gets a fresh instance and disposes the previous one.
|
|
153
|
+
|
|
154
|
+
## A selector that builds an object
|
|
155
|
+
|
|
156
|
+
A selector answering a fresh object is never identical to its own last answer,
|
|
157
|
+
so the reader wakes for EVERY change in the ViewModel — including the keys the
|
|
158
|
+
selector exists to ignore. Hold it:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { createLankaShallowHold } from "lanka/viewmodel";
|
|
162
|
+
|
|
163
|
+
private readonly hold = createLankaShallowHold<{ title: string }>();
|
|
164
|
+
protected readonly mission = useLankaVM(missionVM, (s) => this.hold({ title: s.title }));
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
One hold per reader, declared as a field ABOVE the read, because initialisers run top to bottom — never at module level and never
|
|
168
|
+
shared between two components. A selector answering a **primitive** needs none
|
|
169
|
+
of this. The comparison is one level deep: own keys, same count, `Object.is` on
|
|
170
|
+
each value, arrays included.
|
|
171
|
+
|
|
172
|
+
## Never do these
|
|
173
|
+
|
|
174
|
+
- **Never pass an object-building selector without a hold.** The reader then
|
|
175
|
+
wakes for every change in the ViewModel, selector or no selector.
|
|
176
|
+
- **Never call `useLankaVM` or `toLankaSignals` outside an injection context.**
|
|
177
|
+
It throws by design; use `runInInjectionContext`, or `toLankaObservable`, which
|
|
178
|
+
needs none.
|
|
179
|
+
- **Never call either in `ngOnInit`.** That is not an injection context — a field
|
|
180
|
+
initialiser or the constructor is.
|
|
181
|
+
- **Never forget to await `renderWithLanka`.** It returns a promise, and an
|
|
182
|
+
un-awaited render asserts against a component that has not compiled.
|
|
183
|
+
- **Never wrap `toLankaSignals` around a state whose shape changes at runtime.**
|
|
184
|
+
The field list is read once; `useLankaVM` is the answer for that case.
|
|
185
|
+
- **Never add `rxjs` as a dependency for `toLankaObservable`.** It is
|
|
186
|
+
structurally a `Subscribable`; `from(…)` gives you the operators.
|
|
187
|
+
|
|
188
|
+
## Symptom → cause
|
|
189
|
+
|
|
190
|
+
| What you see | What it is |
|
|
191
|
+
| -------------------------------------------------- | ------------------------------------------------------------------ |
|
|
192
|
+
| a screen repainting for changes it never selected | an object selector with no hold |
|
|
193
|
+
| "must be called in an injection context" | the call is in `ngOnInit`, a method or a module |
|
|
194
|
+
| the template never updates, no error | the tracking blind spot — a derived getter |
|
|
195
|
+
| the `async` pipe renders nothing on the first pass | a stream that is not this one — this one replays the current state |
|
|
196
|
+
| a test asserting against an empty template | `renderWithLanka` not awaited |
|
|
197
|
+
| a field missing from `toLankaSignals` | it appeared after the call — read it through `useLankaVM` |
|
|
198
|
+
|
|
199
|
+
## More
|
|
200
|
+
|
|
201
|
+
`reference.md` — the full guide: the three spellings in detail, the injection
|
|
202
|
+
context rule, and what this package deliberately is not.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
<!-- Generated from modules/bindings/angular/GUIDE.md by scripts/skills.mjs. Edit the guide. -->
|
|
2
|
+
|
|
3
|
+
> **`@lankajs/angular@0.1.0`** — this document describes that version.
|
|
4
|
+
>
|
|
5
|
+
> Install: `npm install @lankajs/angular @angular/core zustand` (the peers are not optional; only npm adds a missing one for you).
|
|
6
|
+
>
|
|
7
|
+
> Complete code, compiled and run in CI: [modules/bindings/angular/_playground/playground.test.ts](https://github.com/lankajs/lanka/blob/main/modules/bindings/angular/_playground/playground.test.ts)
|
|
8
|
+
|
|
9
|
+
# @lankajs/angular — user guide
|
|
10
|
+
|
|
11
|
+
How an Angular component reads a lanka ViewModel.
|
|
12
|
+
|
|
13
|
+
## You will learn
|
|
14
|
+
|
|
15
|
+
- the one call this package publishes, and what it answers
|
|
16
|
+
- when a component updates and when it deliberately does not
|
|
17
|
+
- why a selector that builds an object needs a hold, and when it needs nothing
|
|
18
|
+
- why this binding refuses a call the other four merely warn about
|
|
19
|
+
- how to test an Angular component with a live framework behind it
|
|
20
|
+
|
|
21
|
+
## When to reach for this
|
|
22
|
+
|
|
23
|
+
Reach for it the moment an Angular component has to read a lanka ViewModel — that
|
|
24
|
+
is the whole job, and there is no other supported way to do it. Install this one
|
|
25
|
+
package and no other binding: the five are alternatives, not layers.
|
|
26
|
+
|
|
27
|
+
You do NOT need it to reach the rest of the framework. Gateways, scenarios and
|
|
28
|
+
the locator are plain calls with no view in them, and `viewModel.getState()`
|
|
29
|
+
works anywhere, including on a server.
|
|
30
|
+
|
|
31
|
+
> [!NOTE]
|
|
32
|
+
> Everything below is how this package is _meant_ to be used, not how it must
|
|
33
|
+
> be. The framework bends at the seams it publishes — see
|
|
34
|
+
> [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md) for what is checked and what is
|
|
35
|
+
> merely advice.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install @lankajs/angular @angular/core zustand
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> [!IMPORTANT]
|
|
44
|
+
> `@angular/core` is already in your project; `zustand` is `lanka`'s own peer.
|
|
45
|
+
> Zoneless needs no extra step — a signal is what zoneless change detection
|
|
46
|
+
> reads. npm adds a missing peer for you and pnpm does not, so the line names
|
|
47
|
+
> all of them.
|
|
48
|
+
|
|
49
|
+
## The one call
|
|
50
|
+
|
|
51
|
+
`useLankaVM` is a function. Every member of `modules/bindings/` publishes that
|
|
52
|
+
same name, so moving a screen from one framework to another rewrites the view and
|
|
53
|
+
not the vocabulary.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { Component } from "@angular/core";
|
|
57
|
+
import { useLankaVM } from "@lankajs/angular";
|
|
58
|
+
import { todoVM } from "./todoVM";
|
|
59
|
+
|
|
60
|
+
@Component({
|
|
61
|
+
standalone: true,
|
|
62
|
+
template: `
|
|
63
|
+
<p *ngIf="state().isLoading">loading</p>
|
|
64
|
+
<ul *ngIf="!state().isLoading">
|
|
65
|
+
<li *ngFor="let row of state().rows">{{ row }}</li>
|
|
66
|
+
</ul>
|
|
67
|
+
`,
|
|
68
|
+
})
|
|
69
|
+
export class TodoScreen {
|
|
70
|
+
protected readonly state = useLankaVM(todoVM);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It answers a **`Signal`** — the one thing this shelf does not make uniform,
|
|
75
|
+
because that is Angular's own idea of reactivity and a binding that hid it would
|
|
76
|
+
be a second reactivity system fighting the first.
|
|
77
|
+
|
|
78
|
+
**Zoneless needs no extra step.** A signal is what zoneless change detection
|
|
79
|
+
reads, so this is the shape Angular is moving towards rather than a bridge to it.
|
|
80
|
+
With zones it works unchanged.
|
|
81
|
+
|
|
82
|
+
## A signal per field, the way a service exposes state
|
|
83
|
+
|
|
84
|
+
`useLankaVM` answers ONE `Signal` over the whole state, which is the shape every
|
|
85
|
+
other binding on the shelf parallels: `state().rows`.
|
|
86
|
+
|
|
87
|
+
An Angular service exposes a signal per field and a template reads `rows()`, so
|
|
88
|
+
this package publishes that too:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { toLankaSignals } from "@lankajs/angular";
|
|
92
|
+
|
|
93
|
+
@Component({
|
|
94
|
+
template: `
|
|
95
|
+
@if (todos.isLoading()) {
|
|
96
|
+
<p>loading</p>
|
|
97
|
+
}
|
|
98
|
+
@for (row of todos.rows(); track row) {
|
|
99
|
+
<li>{{ row }}</li>
|
|
100
|
+
}
|
|
101
|
+
`,
|
|
102
|
+
})
|
|
103
|
+
export class TodoScreen {
|
|
104
|
+
protected readonly todos = toLankaSignals(todosVM);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Actions come through as plain functions — `todos.load()` — because an action is
|
|
109
|
+
one object for the life of the store and a signal would make every call site
|
|
110
|
+
write `load()()`.
|
|
111
|
+
|
|
112
|
+
There is ONE subscription behind the whole set, and each field is a `computed`
|
|
113
|
+
over it, so Angular's own deduplication does the rest: a `computed` whose value
|
|
114
|
+
has not changed notifies nobody.
|
|
115
|
+
|
|
116
|
+
The field list is read once, at the call. A ViewModel declares its state up
|
|
117
|
+
front, so that is the whole of it — and `useLankaVM` is the answer for a state
|
|
118
|
+
whose shape is genuinely dynamic.
|
|
119
|
+
|
|
120
|
+
It must be called in an injection context, for the reason `useLankaVM` must:
|
|
121
|
+
`DestroyRef` is the only way to learn the caller has gone.
|
|
122
|
+
|
|
123
|
+
## A stream, for the half of Angular that speaks RxJS
|
|
124
|
+
|
|
125
|
+
Angular is signals-first and `useLankaVM` and `toLankaSignals` answer signals,
|
|
126
|
+
which is the right default. It is also a framework with fifteen years of
|
|
127
|
+
`Observable` in it — the `async` pipe, `HttpClient`, the router's events, every
|
|
128
|
+
`switchMap` a codebase already has — and a signal is not something you can pass
|
|
129
|
+
to `combineLatest`.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { toLankaObservable } from "@lankajs/angular";
|
|
133
|
+
|
|
134
|
+
@Component({
|
|
135
|
+
template: `@if (todos$ | async; as todos) {
|
|
136
|
+
…
|
|
137
|
+
}`,
|
|
138
|
+
})
|
|
139
|
+
export class TodoScreen {
|
|
140
|
+
protected readonly todos$ = toLankaObservable(todosVM);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
It emits the CURRENT state first, like a `BehaviorSubject`, so a template
|
|
145
|
+
rendering `| async` shows something on the first pass. Each subscriber gets its
|
|
146
|
+
own recording, so a subscriber reading only `rows` is not woken by `unread`.
|
|
147
|
+
|
|
148
|
+
**It needs no injection context**, unlike the two signal spellings: a stream's
|
|
149
|
+
subscriber holds its own unsubscribe, which is RxJS's answer to the question
|
|
150
|
+
`DestroyRef` answers for a signal. So it works in a service, a resolver, an
|
|
151
|
+
interceptor and a plain function.
|
|
152
|
+
|
|
153
|
+
**It imports no `rxjs`.** `AsyncPipe` accepts `Subscribable<T>` — one method — so
|
|
154
|
+
this satisfies the contract structurally and `@lankajs/angular` goes on importing
|
|
155
|
+
nothing but `@angular/core`. Pipe it when you want the operators:
|
|
156
|
+
`from(toLankaObservable(vm))` takes a subscribable, and `toObservable` from
|
|
157
|
+
`@angular/core/rxjs-interop` takes the signal `useLankaVM` answers.
|
|
158
|
+
|
|
159
|
+
## What updates, and what does not
|
|
160
|
+
|
|
161
|
+
Without a selector the signal carries a value that RECORDS which keys you read.
|
|
162
|
+
The next change updates it only if one of those moved — so a component reading
|
|
163
|
+
`rows` does not repaint because a spinner somewhere else turned off.
|
|
164
|
+
|
|
165
|
+
With a selector, the selector decides and tracking is bypassed:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
protected readonly count = useLankaVM(todoVM, (state) => state.rows.length);
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
> [!WARNING]
|
|
172
|
+
> **The blind spot.** Tracking sees keys you read DIRECTLY. A key reached only
|
|
173
|
+
> inside a derived getter — an action calling `get()` — is invisible to it, so a
|
|
174
|
+
> change to that key updates nothing and the screen freezes with no error.
|
|
175
|
+
>
|
|
176
|
+
> Set `enableAccessTrackingOptimization: false` on such a ViewModel. Do NOT patch
|
|
177
|
+
> it in the template by reading the underlying keys "for the side effect": that
|
|
178
|
+
> is dead code, and a refactor or a lint autofix removes it.
|
|
179
|
+
>
|
|
180
|
+
> In development the framework announces the mismatch by ViewModel and key name.
|
|
181
|
+
|
|
182
|
+
## A selector that builds its answer
|
|
183
|
+
|
|
184
|
+
`useLankaVM(vm, (state) => ({ … }))` is safe — nothing loops — but on its own it
|
|
185
|
+
updates the signal for **every** change in the ViewModel, including the keys the
|
|
186
|
+
selector exists to ignore. The reason is identity: that object is new on every
|
|
187
|
+
call, and a binding compares selections with `Object.is`.
|
|
188
|
+
|
|
189
|
+
`createLankaShallowHold` is the comparison that fixes it. It answers the
|
|
190
|
+
PREVIOUS object while nothing in the selection moved, one level deep — own keys,
|
|
191
|
+
same count, `Object.is` on each value, arrays included:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { createLankaShallowHold } from "lanka/viewmodel";
|
|
195
|
+
import { useLankaVM } from "@lankajs/angular";
|
|
196
|
+
|
|
197
|
+
@Component({
|
|
198
|
+
template: `<h1>{{ mission().title }} — {{ mission().status }}</h1>`,
|
|
199
|
+
})
|
|
200
|
+
export class MissionScreen {
|
|
201
|
+
private readonly hold = createLankaShallowHold<{ title: string; status: string }>();
|
|
202
|
+
|
|
203
|
+
protected readonly mission = useLankaVM(missionVM, (state) =>
|
|
204
|
+
this.hold({ title: state.title, status: state.status }),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The hold is declared ABOVE the read, because field initialisers run top to
|
|
210
|
+
bottom. One per component — never a `static`, and never shared between two of
|
|
211
|
+
them, because the answer it holds belongs to whoever selected it.
|
|
212
|
+
|
|
213
|
+
A selector answering a **primitive** needs none of this: `(state) => state.title`
|
|
214
|
+
compares equal to itself and was always free. A selection with a **nested**
|
|
215
|
+
object wants a selector that picks the leaves — comparing deeper would mean
|
|
216
|
+
walking a state of unknown size on every read, which is the cost a selector was
|
|
217
|
+
taken to avoid.
|
|
218
|
+
|
|
219
|
+
## It must be called in an injection context
|
|
220
|
+
|
|
221
|
+
A constructor, a field initialiser, a factory, or inside
|
|
222
|
+
`runInInjectionContext`. This binding asserts it, and the message names the fix.
|
|
223
|
+
|
|
224
|
+
That is stricter than `@lankajs/vue` and `@lankajs/solid`, which publish a
|
|
225
|
+
`stop()` for a call made outside their framework's scope. Their reason is that
|
|
226
|
+
both can still work without one. Angular cannot: `DestroyRef` is the only way to
|
|
227
|
+
learn that the caller has gone, and a subscription that cannot learn that is a
|
|
228
|
+
leak with no owner.
|
|
229
|
+
|
|
230
|
+
So this one refuses at the call rather than leaking quietly — a refusal you read
|
|
231
|
+
once beats a leak found in production.
|
|
232
|
+
|
|
233
|
+
## Testing
|
|
234
|
+
|
|
235
|
+
`@lankajs/angular/testing` renders a component with a bootstrapped framework, so
|
|
236
|
+
a component test needs no bootstrap preamble of its own:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import { renderWithLanka } from "@lankajs/angular/testing";
|
|
240
|
+
|
|
241
|
+
await renderWithLanka(TodoScreen, {
|
|
242
|
+
fakes: { gateways: { TodoGateway: { list: () => Promise.resolve([]) } } },
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
It is `await`ed where the other four bindings' are not: Angular Testing Library
|
|
247
|
+
drives `TestBed`, which COMPILES a component rather than merely mounting one.
|
|
248
|
+
That difference belongs to Angular's testing story and not to lanka, which is why
|
|
249
|
+
it is not hidden behind a synchronous wrapper.
|
|
250
|
+
|
|
251
|
+
Every call gets a FRESH instance and disposes the previous one, so a test never
|
|
252
|
+
inherits its neighbour's subscriptions.
|
|
253
|
+
|
|
254
|
+
## What this package is not
|
|
255
|
+
|
|
256
|
+
It is a subscription and a signal, and nothing else. The recording of which keys
|
|
257
|
+
you read, the comparison that decides whether a change is worth an update, and
|
|
258
|
+
the blind-spot warning are all in `lanka` itself — which is why the behaviour you
|
|
259
|
+
see is the framework's rather than this package's reading of it, and why
|
|
260
|
+
`lankaViewBindingConformance` can hold every binding to one list.
|
|
261
|
+
|
|
262
|
+
If this package ever needs more than the ViewModel port gives it, the port has
|
|
263
|
+
the defect and the fix belongs in `lanka`, for every framework at once.
|
|
264
|
+
|
|
265
|
+
## Recap
|
|
266
|
+
|
|
267
|
+
- `useLankaVM(todoVM)` is the one call, and every binding publishes that name.
|
|
268
|
+
- It answers a read-only `Signal`: `state().rows` in a component, `{{ state().rows }}` in a template.
|
|
269
|
+
- It must be called in an injection context, and it says so: `DestroyRef` is the only way to learn the caller has gone.
|
|
270
|
+
- A key reached only through a derived getter is invisible to tracking: set `enableAccessTrackingOptimization: false` on that ViewModel.
|
|
271
|
+
- `toLankaSignals` gives a signal per field; `toLankaObservable` is the bridge for code that already speaks RxJS.
|
|
272
|
+
- `renderWithLanka` from `@lankajs/angular/testing` drives `TestBed`, so a field initialiser is inside an injection context in a test too.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
Maintaining this package: [SKILL.md](https://github.com/lankajs/lanka/blob/main/modules/bindings/angular/SKILL.md) · What it is:
|
|
277
|
+
[README.md](https://github.com/lankajs/lanka/blob/main/modules/bindings/angular/README.md) · Repository map: [../../../README.md](https://github.com/lankajs/lanka/blob/main/README.md)
|