@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lankajs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @lankajs/angular
|
|
2
|
+
|
|
3
|
+
**▸ module** · Angular binding
|
|
4
|
+
|
|
5
|
+
> One function — `useLankaVM` — over a signal, and the access tracking core already does.
|
|
6
|
+
|
|
7
|
+
A library in the same box. The app imports and calls it; core does not know it exists.
|
|
8
|
+
|
|
9
|
+
**Runs in:** the browser.
|
|
10
|
+
|
|
11
|
+
**Requires:** Angular. Enforced by `check-runtime.mjs`, which refuses an import of any other.
|
|
12
|
+
|
|
13
|
+
**How to use it:** [GUIDE.md](./GUIDE.md) — the user guide, with examples. **How to change it:** [SKILL.md](./SKILL.md).
|
|
14
|
+
|
|
15
|
+
## Contents
|
|
16
|
+
|
|
17
|
+
- `useLankaVM` — the one name, and the same one every member of this shelf publishes
|
|
18
|
+
|
|
19
|
+
## What an Angular call answers
|
|
20
|
+
|
|
21
|
+
A `Signal`: `state().rows` in a component, `{{ state().rows }}` in a template. Zoneless
|
|
22
|
+
works with no extra step, because a signal is what zoneless change detection reads.
|
|
23
|
+
|
|
24
|
+
## The injection context, and why it is required rather than optional
|
|
25
|
+
|
|
26
|
+
`useLankaVM` must be called where Angular can inject — a constructor, a field
|
|
27
|
+
initialiser, a factory, or inside `runInInjectionContext`. It asserts that, and the
|
|
28
|
+
message names the fix.
|
|
29
|
+
|
|
30
|
+
That is stricter than the Vue and Solid bindings, which publish a `stop()` for a call
|
|
31
|
+
made outside their scope. Angular's reason is different: `DestroyRef` is the ONLY way
|
|
32
|
+
to know when the caller goes away, and a subscription with no way to learn that is a
|
|
33
|
+
leak with no owner. Where Vue and Solid degrade, Angular refuses — and refusing at the
|
|
34
|
+
call is better than a leak discovered in production.
|
|
35
|
+
|
|
36
|
+
## The injection context reaches a test too
|
|
37
|
+
|
|
38
|
+
`renderWithLanka` from `@lankajs/angular/testing` renders a component with a live
|
|
39
|
+
framework behind it, the way every other member of this shelf does. What differs is
|
|
40
|
+
underneath: Angular Testing Library drives `TestBed`, so a component here is compiled
|
|
41
|
+
rather than merely mounted — and a ViewModel read in a field initialiser is inside an
|
|
42
|
+
injection context, which is the one thing this binding insists on.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
Repository map: [../../../README.md](../../../README.md)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
8
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
19
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
20
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
21
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
22
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
23
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
24
|
+
mod
|
|
25
|
+
));
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
__commonJS,
|
|
29
|
+
__toESM
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=chunk-5WRI5ZAA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ILankaReadableVM } from 'lanka/viewmodel';
|
|
2
|
+
import { Signal } from '@angular/core';
|
|
3
|
+
|
|
4
|
+
/** What a subscriber hands in, and what it gets back. */
|
|
5
|
+
interface ILankaObserver<TValue> {
|
|
6
|
+
next?: (value: TValue) => void;
|
|
7
|
+
error?: (failure: unknown) => void;
|
|
8
|
+
complete?: () => void;
|
|
9
|
+
}
|
|
10
|
+
/** What unsubscribing looks like, in RxJS's own vocabulary. */
|
|
11
|
+
interface ILankaUnsubscribable {
|
|
12
|
+
unsubscribe: () => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A ViewModel as something the `async` pipe and an RxJS chain accept.
|
|
16
|
+
*
|
|
17
|
+
* `Subscribable` is the whole contract: one method, and `AsyncPipe` takes it as
|
|
18
|
+
* readily as an `Observable`.
|
|
19
|
+
*/
|
|
20
|
+
interface ILankaObservableVM<TState extends object> {
|
|
21
|
+
subscribe: (observer: ILankaObserver<TState> | ((value: TState) => void)) => ILankaUnsubscribable;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Reads a ViewModel as a stream, for the half of Angular that speaks RxJS.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* @Component({ template: `@if (todos$ | async; as todos) { … }` })
|
|
28
|
+
* export class TodoScreen {
|
|
29
|
+
* protected readonly todos$ = toLankaObservable(todosVM);
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* // or in a chain, where signals cannot go
|
|
35
|
+
* toLankaObservable(todosVM).subscribe(({ rows }) => this.log(rows.length));
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* ## Why this exists beside `useLankaVM` and `toLankaSignals`
|
|
39
|
+
*
|
|
40
|
+
* Angular is signals-first now and those two answer signals, which is the right
|
|
41
|
+
* default. It is also a framework with fifteen years of `Observable` in it: the
|
|
42
|
+
* `async` pipe, `HttpClient`, the router's events, every `switchMap` a codebase
|
|
43
|
+
* already has. A consumer with a stream in hand reaches for `combineLatest`, and
|
|
44
|
+
* a signal is not something they can pass to it.
|
|
45
|
+
*
|
|
46
|
+
* ## No `rxjs` import, deliberately
|
|
47
|
+
*
|
|
48
|
+
* `AsyncPipe` accepts `Subscribable<T>`, which is an INTERFACE — one method — so
|
|
49
|
+
* this satisfies it structurally and adds no dependency. The parity canon's
|
|
50
|
+
* order for an idiom is the framework's own library first, then what it already
|
|
51
|
+
* requires, then a few lines written here, and only then somebody else's
|
|
52
|
+
* package. This is the third rung, and it keeps `@lankajs/angular` importing
|
|
53
|
+
* nothing but `@angular/core`.
|
|
54
|
+
*
|
|
55
|
+
* A consumer who wants the operators pipes it: `from(toLankaObservable(vm))`
|
|
56
|
+
* takes a subscribable, and `toObservable` from `@angular/core/rxjs-interop`
|
|
57
|
+
* takes the signal `useLankaVM` answers.
|
|
58
|
+
*
|
|
59
|
+
* ## It emits the CURRENT state first
|
|
60
|
+
*
|
|
61
|
+
* Like a `BehaviorSubject` and like every store an Angular consumer has met: a
|
|
62
|
+
* subscriber gets the state it subscribed to before anything changes, because a
|
|
63
|
+
* template rendering `| async` would otherwise show nothing until the first
|
|
64
|
+
* write.
|
|
65
|
+
*
|
|
66
|
+
* ## No injection context needed
|
|
67
|
+
*
|
|
68
|
+
* Unlike `useLankaVM` and `toLankaSignals`, which take a `DestroyRef` because a
|
|
69
|
+
* signal has no other way to learn its reader has gone. A stream's subscriber
|
|
70
|
+
* holds its own unsubscribe, which is RxJS's answer to the same question — so
|
|
71
|
+
* this works in a service, a resolver, an interceptor and a plain function.
|
|
72
|
+
*/
|
|
73
|
+
declare const toLankaObservable: <TState extends object>(viewModel: ILankaReadableVM<TState>) => ILankaObservableVM<TState>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A ViewModel split the way an Angular service exposes state: a signal per
|
|
77
|
+
* value, and the actions as themselves.
|
|
78
|
+
*
|
|
79
|
+
* An action is one object for the life of the store, so wrapping it in a signal
|
|
80
|
+
* would make every call site write `load()()`. A value changes, so it is a
|
|
81
|
+
* signal; a function does not, so it is a function.
|
|
82
|
+
*/
|
|
83
|
+
type TLankaSignals<TState extends object> = {
|
|
84
|
+
[TKey in keyof TState]: TState[TKey] extends (...args: never[]) => unknown ? TState[TKey] : Signal<TState[TKey]>;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Reads a ViewModel as the signals an Angular component expects.
|
|
88
|
+
*
|
|
89
|
+
* ```ts
|
|
90
|
+
* @Component({ template: `@if (todos.isLoading()) { … } @for (row of todos.rows(); track row) { … }` })
|
|
91
|
+
* export class TodoScreen {
|
|
92
|
+
* protected readonly todos = toLankaSignals(todosVM);
|
|
93
|
+
* }
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* ## Why this exists beside `useLankaVM`
|
|
97
|
+
*
|
|
98
|
+
* `useLankaVM` answers ONE `Signal` over the whole state, which is the shape
|
|
99
|
+
* every other binding on the shelf parallels: `state().rows`. It is also not how
|
|
100
|
+
* Angular holds state. An Angular service exposes a signal per field —
|
|
101
|
+
* `readonly rows = signal([])` — and a template reads `rows()`, never
|
|
102
|
+
* `state().rows`. A consumer with that habit reaches for `todos.rows()` and finds
|
|
103
|
+
* a call on a plain object.
|
|
104
|
+
*
|
|
105
|
+
* ## One subscription, and each signal is a `computed` over it
|
|
106
|
+
*
|
|
107
|
+
* There is one `subscribe` on the ViewModel and one version signal behind every
|
|
108
|
+
* field, so a change wakes Angular once and each `computed` decides for itself
|
|
109
|
+
* whether its own value moved. That is Angular's own deduplication — a `computed`
|
|
110
|
+
* whose result is unchanged notifies nobody — arriving for free, and it is why
|
|
111
|
+
* this is not a second subscription per field.
|
|
112
|
+
*
|
|
113
|
+
* ## The keys are read ONCE, at the call
|
|
114
|
+
*
|
|
115
|
+
* A ViewModel declares its state up front, so the field list is fixed at the
|
|
116
|
+
* moment this is called. A key added to the state later has no signal here, and
|
|
117
|
+
* that is the price of the shape: Angular's own services name their fields too.
|
|
118
|
+
* `useLankaVM` is the answer for a state whose shape is genuinely dynamic.
|
|
119
|
+
*
|
|
120
|
+
* ## It needs an injection context, for the reason `useLankaVM` does
|
|
121
|
+
*
|
|
122
|
+
* `DestroyRef` is the only way to learn the caller has gone, and a subscription
|
|
123
|
+
* that cannot learn that is a leak with no owner.
|
|
124
|
+
*/
|
|
125
|
+
declare const toLankaSignals: <TState extends object>(viewModel: ILankaReadableVM<TState>) => TLankaSignals<TState>;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reads a ViewModel from Angular.
|
|
129
|
+
*
|
|
130
|
+
* ```ts
|
|
131
|
+
* @Component({ template: `<li *ngFor="let row of state().rows">{{ row }}</li>` })
|
|
132
|
+
* export class TodoScreen {
|
|
133
|
+
* protected readonly state = useLankaVM(todoVM);
|
|
134
|
+
* }
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* Without a selector the signal carries a value that RECORDS which keys were
|
|
138
|
+
* read, and changes only when one of THOSE moves. With a selector the selector
|
|
139
|
+
* decides and tracking is bypassed.
|
|
140
|
+
*
|
|
141
|
+
* Zoneless needs no extra step: a signal is what zoneless change detection
|
|
142
|
+
* reads, so this is the shape Angular is moving towards rather than a bridge to
|
|
143
|
+
* it.
|
|
144
|
+
*
|
|
145
|
+
* ## Why an injection context is REQUIRED, not preferred
|
|
146
|
+
*
|
|
147
|
+
* `@lankajs/vue` and `@lankajs/solid` publish a `stop()` for a call made outside
|
|
148
|
+
* their framework's scope, because both can still work without one. Angular
|
|
149
|
+
* cannot: `DestroyRef` is the only way to learn that the caller has gone, and a
|
|
150
|
+
* subscription with no way to learn that is a leak with no owner.
|
|
151
|
+
*
|
|
152
|
+
* So this refuses at the call rather than leaking quietly, and the message names
|
|
153
|
+
* the fix. Where the other two degrade, this one stops — and a refusal a
|
|
154
|
+
* developer reads once beats a leak found in production.
|
|
155
|
+
*
|
|
156
|
+
* ## What this function does NOT contain
|
|
157
|
+
*
|
|
158
|
+
* The recording, the comparison and the blind-spot warning are
|
|
159
|
+
* `createLankaAccessTracker` in core. Every binding on this shelf calls it,
|
|
160
|
+
* which is what makes "a screen updates for the keys it read" a fact about lanka
|
|
161
|
+
* rather than a fact about Angular.
|
|
162
|
+
*/
|
|
163
|
+
declare function useLankaVM<TState extends object>(viewModel: ILankaReadableVM<TState>): Signal<TState>;
|
|
164
|
+
declare function useLankaVM<TState extends object, TSelected>(viewModel: ILankaReadableVM<TState>, selector: (state: TState) => TSelected): Signal<TSelected>;
|
|
165
|
+
|
|
166
|
+
export { type ILankaObservableVM, type ILankaObserver, type ILankaUnsubscribable, type TLankaSignals, toLankaObservable, toLankaSignals, useLankaVM };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import "./chunk-5WRI5ZAA.js";
|
|
2
|
+
|
|
3
|
+
// src/to-lanka-observable/toLankaObservable.ts
|
|
4
|
+
import { createLankaViewSubscription } from "lanka/extend";
|
|
5
|
+
var toLankaObservable = (viewModel) => ({
|
|
6
|
+
subscribe: (observer) => {
|
|
7
|
+
const next = typeof observer === "function" ? observer : observer.next ?? (() => void 0);
|
|
8
|
+
const view = createLankaViewSubscription(viewModel, () => next(view.read()));
|
|
9
|
+
next(view.read());
|
|
10
|
+
return { unsubscribe: view.stop };
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// src/to-lanka-signals/toLankaSignals.ts
|
|
15
|
+
import { DestroyRef, assertInInjectionContext, computed, inject, signal } from "@angular/core";
|
|
16
|
+
import { createLankaViewSubscription as createLankaViewSubscription2 } from "lanka/extend";
|
|
17
|
+
var toLankaSignals = (viewModel) => {
|
|
18
|
+
assertInInjectionContext(toLankaSignals);
|
|
19
|
+
const version = signal(0);
|
|
20
|
+
const view = createLankaViewSubscription2(viewModel, () => {
|
|
21
|
+
version.update((seen) => seen + 1);
|
|
22
|
+
});
|
|
23
|
+
inject(DestroyRef).onDestroy(view.stop);
|
|
24
|
+
const signals = {};
|
|
25
|
+
for (const [key, value] of Object.entries(viewModel.getState())) {
|
|
26
|
+
signals[key] = typeof value === "function" ? value : computed(() => {
|
|
27
|
+
version();
|
|
28
|
+
return view.read()[key];
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return signals;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/use-lanka-vm/useLankaVM.ts
|
|
35
|
+
import { DestroyRef as DestroyRef2, assertInInjectionContext as assertInInjectionContext2, inject as inject2, signal as signal2 } from "@angular/core";
|
|
36
|
+
import { createLankaAccessTracker } from "lanka/extend";
|
|
37
|
+
function useLankaVM(viewModel, selector) {
|
|
38
|
+
assertInInjectionContext2(useLankaVM);
|
|
39
|
+
const tracker = createLankaAccessTracker(viewModel);
|
|
40
|
+
const read = () => selector ? selector(viewModel.getState()) : tracker.read();
|
|
41
|
+
const state = signal2(read(), { equal: () => false });
|
|
42
|
+
const stop = viewModel.subscribe((next, prev) => {
|
|
43
|
+
if (selector) {
|
|
44
|
+
const picked = read();
|
|
45
|
+
if (Object.is(picked, state())) return;
|
|
46
|
+
state.set(picked);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!tracker.shouldNotify(next, prev)) {
|
|
50
|
+
tracker.reportSkipped(next, prev);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
state.set(read());
|
|
54
|
+
});
|
|
55
|
+
inject2(DestroyRef2).onDestroy(stop);
|
|
56
|
+
return state.asReadonly();
|
|
57
|
+
}
|
|
58
|
+
export {
|
|
59
|
+
toLankaObservable,
|
|
60
|
+
toLankaSignals,
|
|
61
|
+
useLankaVM
|
|
62
|
+
};
|
|
63
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/to-lanka-observable/toLankaObservable.ts","../src/to-lanka-signals/toLankaSignals.ts","../src/use-lanka-vm/useLankaVM.ts"],"sourcesContent":["import { createLankaViewSubscription } from \"lanka/extend\";\nimport type { ILankaReadableVM } from \"lanka/viewmodel\";\n\n/** What a subscriber hands in, and what it gets back. */\nexport interface ILankaObserver<TValue> {\n\tnext?: (value: TValue) => void;\n\terror?: (failure: unknown) => void;\n\tcomplete?: () => void;\n}\n\n/** What unsubscribing looks like, in RxJS's own vocabulary. */\nexport interface ILankaUnsubscribable {\n\tunsubscribe: () => void;\n}\n\n/**\n * A ViewModel as something the `async` pipe and an RxJS chain accept.\n *\n * `Subscribable` is the whole contract: one method, and `AsyncPipe` takes it as\n * readily as an `Observable`.\n */\nexport interface ILankaObservableVM<TState extends object> {\n\tsubscribe: (\n\t\tobserver: ILankaObserver<TState> | ((value: TState) => void),\n\t) => ILankaUnsubscribable;\n}\n\n/**\n * Reads a ViewModel as a stream, for the half of Angular that speaks RxJS.\n *\n * ```ts\n * @Component({ template: `@if (todos$ | async; as todos) { … }` })\n * export class TodoScreen {\n * \tprotected readonly todos$ = toLankaObservable(todosVM);\n * }\n * ```\n *\n * ```ts\n * // or in a chain, where signals cannot go\n * toLankaObservable(todosVM).subscribe(({ rows }) => this.log(rows.length));\n * ```\n *\n * ## Why this exists beside `useLankaVM` and `toLankaSignals`\n *\n * Angular is signals-first now and those two answer signals, which is the right\n * default. It is also a framework with fifteen years of `Observable` in it: the\n * `async` pipe, `HttpClient`, the router's events, every `switchMap` a codebase\n * already has. A consumer with a stream in hand reaches for `combineLatest`, and\n * a signal is not something they can pass to it.\n *\n * ## No `rxjs` import, deliberately\n *\n * `AsyncPipe` accepts `Subscribable<T>`, which is an INTERFACE — one method — so\n * this satisfies it structurally and adds no dependency. The parity canon's\n * order for an idiom is the framework's own library first, then what it already\n * requires, then a few lines written here, and only then somebody else's\n * package. This is the third rung, and it keeps `@lankajs/angular` importing\n * nothing but `@angular/core`.\n *\n * A consumer who wants the operators pipes it: `from(toLankaObservable(vm))`\n * takes a subscribable, and `toObservable` from `@angular/core/rxjs-interop`\n * takes the signal `useLankaVM` answers.\n *\n * ## It emits the CURRENT state first\n *\n * Like a `BehaviorSubject` and like every store an Angular consumer has met: a\n * subscriber gets the state it subscribed to before anything changes, because a\n * template rendering `| async` would otherwise show nothing until the first\n * write.\n *\n * ## No injection context needed\n *\n * Unlike `useLankaVM` and `toLankaSignals`, which take a `DestroyRef` because a\n * signal has no other way to learn its reader has gone. A stream's subscriber\n * holds its own unsubscribe, which is RxJS's answer to the same question — so\n * this works in a service, a resolver, an interceptor and a plain function.\n */\nexport const toLankaObservable = <TState extends object>(\n\tviewModel: ILankaReadableVM<TState>,\n): ILankaObservableVM<TState> => ({\n\tsubscribe: (observer) => {\n\t\tconst next =\n\t\t\ttypeof observer === \"function\" ? observer : (observer.next ?? (() => undefined));\n\n\t\tconst view = createLankaViewSubscription(viewModel, () => next(view.read()));\n\n\t\tnext(view.read());\n\n\t\treturn { unsubscribe: view.stop };\n\t},\n});\n","import { DestroyRef, assertInInjectionContext, computed, inject, signal } from \"@angular/core\";\nimport { createLankaViewSubscription } from \"lanka/extend\";\nimport type { Signal } from \"@angular/core\";\nimport type { ILankaReadableVM } from \"lanka/viewmodel\";\n\n/**\n * A ViewModel split the way an Angular service exposes state: a signal per\n * value, and the actions as themselves.\n *\n * An action is one object for the life of the store, so wrapping it in a signal\n * would make every call site write `load()()`. A value changes, so it is a\n * signal; a function does not, so it is a function.\n */\nexport type TLankaSignals<TState extends object> = {\n\t[TKey in keyof TState]: TState[TKey] extends (...args: never[]) => unknown\n\t\t? TState[TKey]\n\t\t: Signal<TState[TKey]>;\n};\n\n/**\n * Reads a ViewModel as the signals an Angular component expects.\n *\n * ```ts\n * @Component({ template: `@if (todos.isLoading()) { … } @for (row of todos.rows(); track row) { … }` })\n * export class TodoScreen {\n * \tprotected readonly todos = toLankaSignals(todosVM);\n * }\n * ```\n *\n * ## Why this exists beside `useLankaVM`\n *\n * `useLankaVM` answers ONE `Signal` over the whole state, which is the shape\n * every other binding on the shelf parallels: `state().rows`. It is also not how\n * Angular holds state. An Angular service exposes a signal per field —\n * `readonly rows = signal([])` — and a template reads `rows()`, never\n * `state().rows`. A consumer with that habit reaches for `todos.rows()` and finds\n * a call on a plain object.\n *\n * ## One subscription, and each signal is a `computed` over it\n *\n * There is one `subscribe` on the ViewModel and one version signal behind every\n * field, so a change wakes Angular once and each `computed` decides for itself\n * whether its own value moved. That is Angular's own deduplication — a `computed`\n * whose result is unchanged notifies nobody — arriving for free, and it is why\n * this is not a second subscription per field.\n *\n * ## The keys are read ONCE, at the call\n *\n * A ViewModel declares its state up front, so the field list is fixed at the\n * moment this is called. A key added to the state later has no signal here, and\n * that is the price of the shape: Angular's own services name their fields too.\n * `useLankaVM` is the answer for a state whose shape is genuinely dynamic.\n *\n * ## It needs an injection context, for the reason `useLankaVM` does\n *\n * `DestroyRef` is the only way to learn the caller has gone, and a subscription\n * that cannot learn that is a leak with no owner.\n */\nexport const toLankaSignals = <TState extends object>(\n\tviewModel: ILankaReadableVM<TState>,\n): TLankaSignals<TState> => {\n\tassertInInjectionContext(toLankaSignals);\n\n\tconst version = signal(0);\n\tconst view = createLankaViewSubscription(viewModel, () => {\n\t\tversion.update((seen) => seen + 1);\n\t});\n\n\tinject(DestroyRef).onDestroy(view.stop);\n\n\tconst signals = {} as Record<string, unknown>;\n\n\tfor (const [key, value] of Object.entries(viewModel.getState())) {\n\t\tsignals[key] =\n\t\t\ttypeof value === \"function\"\n\t\t\t\t? value\n\t\t\t\t: computed(() => {\n\t\t\t\t\t\t// Read for the DEPENDENCY, discard the number. The value itself\n\t\t\t\t\t\t// comes from a tracked read, so the key is recorded and this reader\n\t\t\t\t\t\t// is woken only for the keys it has signals for.\n\t\t\t\t\t\tversion();\n\n\t\t\t\t\t\treturn (view.read() as Record<string, unknown>)[key];\n\t\t\t\t\t});\n\t}\n\n\treturn signals as TLankaSignals<TState>;\n};\n","import { DestroyRef, assertInInjectionContext, inject, signal } from \"@angular/core\";\nimport { createLankaAccessTracker } from \"lanka/extend\";\nimport type { Signal } from \"@angular/core\";\nimport type { ILankaReadableVM } from \"lanka/viewmodel\";\n\n/**\n * Reads a ViewModel from Angular.\n *\n * ```ts\n * @Component({ template: `<li *ngFor=\"let row of state().rows\">{{ row }}</li>` })\n * export class TodoScreen {\n * \tprotected readonly state = useLankaVM(todoVM);\n * }\n * ```\n *\n * Without a selector the signal carries a value that RECORDS which keys were\n * read, and changes only when one of THOSE moves. With a selector the selector\n * decides and tracking is bypassed.\n *\n * Zoneless needs no extra step: a signal is what zoneless change detection\n * reads, so this is the shape Angular is moving towards rather than a bridge to\n * it.\n *\n * ## Why an injection context is REQUIRED, not preferred\n *\n * `@lankajs/vue` and `@lankajs/solid` publish a `stop()` for a call made outside\n * their framework's scope, because both can still work without one. Angular\n * cannot: `DestroyRef` is the only way to learn that the caller has gone, and a\n * subscription with no way to learn that is a leak with no owner.\n *\n * So this refuses at the call rather than leaking quietly, and the message names\n * the fix. Where the other two degrade, this one stops — and a refusal a\n * developer reads once beats a leak found in production.\n *\n * ## What this function does NOT contain\n *\n * The recording, the comparison and the blind-spot warning are\n * `createLankaAccessTracker` in core. Every binding on this shelf calls it,\n * which is what makes \"a screen updates for the keys it read\" a fact about lanka\n * rather than a fact about Angular.\n */\nexport function useLankaVM<TState extends object>(\n\tviewModel: ILankaReadableVM<TState>,\n): Signal<TState>;\n\nexport function useLankaVM<TState extends object, TSelected>(\n\tviewModel: ILankaReadableVM<TState>,\n\tselector: (state: TState) => TSelected,\n): Signal<TSelected>;\n\nexport function useLankaVM<TState extends object, TSelected>(\n\tviewModel: ILankaReadableVM<TState>,\n\tselector?: (state: TState) => TSelected,\n): Signal<TState | TSelected> {\n\tassertInInjectionContext(useLankaVM);\n\n\tconst tracker = createLankaAccessTracker(viewModel);\n\tconst read = (): TState | TSelected =>\n\t\tselector ? selector(viewModel.getState()) : tracker.read();\n\n\t// `equal: () => false` because a tracked read hands back the SAME proxy while\n\t// the state object is unchanged, and a signal compares by identity — so\n\t// setting it would be a no-op exactly when the tracker did its job. What\n\t// decides whether anything happens is `shouldNotify` below, which is the\n\t// framework's answer rather than the signal's.\n\tconst state = signal<TState | TSelected>(read(), { equal: () => false });\n\n\tconst stop = viewModel.subscribe((next, prev) => {\n\t\tif (selector) {\n\t\t\tconst picked = read();\n\n\t\t\t// Only when the SELECTION moved. The signal is `equal: () => false`, so\n\t\t\t// setting it always wakes — which is right for a tracked read and wrong\n\t\t\t// for a selected one, and is what the suite's selector scenes refuse.\n\t\t\tif (Object.is(picked, state())) return;\n\n\t\t\tstate.set(picked);\n\n\t\t\treturn;\n\t\t}\n\n\t\tif (!tracker.shouldNotify(next, prev)) {\n\t\t\t// No update will follow. If the changed key is linked to this component\n\t\t\t// through a getter it read, the screen froze — and in development core\n\t\t\t// says so by name.\n\t\t\ttracker.reportSkipped(next, prev);\n\t\t\treturn;\n\t\t}\n\n\t\tstate.set(read());\n\t});\n\tinject(DestroyRef).onDestroy(stop);\n\n\treturn state.asReadonly();\n}\n"],"mappings":";;;AAAA,SAAS,mCAAmC;AA6ErC,IAAM,oBAAoB,CAChC,eACiC;AAAA,EACjC,WAAW,CAAC,aAAa;AACxB,UAAM,OACL,OAAO,aAAa,aAAa,WAAY,SAAS,SAAS,MAAM;AAEtE,UAAM,OAAO,4BAA4B,WAAW,MAAM,KAAK,KAAK,KAAK,CAAC,CAAC;AAE3E,SAAK,KAAK,KAAK,CAAC;AAEhB,WAAO,EAAE,aAAa,KAAK,KAAK;AAAA,EACjC;AACD;;;AC1FA,SAAS,YAAY,0BAA0B,UAAU,QAAQ,cAAc;AAC/E,SAAS,+BAAAA,oCAAmC;AAyDrC,IAAM,iBAAiB,CAC7B,cAC2B;AAC3B,2BAAyB,cAAc;AAEvC,QAAM,UAAU,OAAO,CAAC;AACxB,QAAM,OAAOA,6BAA4B,WAAW,MAAM;AACzD,YAAQ,OAAO,CAAC,SAAS,OAAO,CAAC;AAAA,EAClC,CAAC;AAED,SAAO,UAAU,EAAE,UAAU,KAAK,IAAI;AAEtC,QAAM,UAAU,CAAC;AAEjB,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,UAAU,SAAS,CAAC,GAAG;AAChE,YAAQ,GAAG,IACV,OAAO,UAAU,aACd,QACA,SAAS,MAAM;AAIf,cAAQ;AAER,aAAQ,KAAK,KAAK,EAA8B,GAAG;AAAA,IACpD,CAAC;AAAA,EACL;AAEA,SAAO;AACR;;;ACvFA,SAAS,cAAAC,aAAY,4BAAAC,2BAA0B,UAAAC,SAAQ,UAAAC,eAAc;AACrE,SAAS,gCAAgC;AAiDlC,SAAS,WACf,WACA,UAC6B;AAC7B,EAAAF,0BAAyB,UAAU;AAEnC,QAAM,UAAU,yBAAyB,SAAS;AAClD,QAAM,OAAO,MACZ,WAAW,SAAS,UAAU,SAAS,CAAC,IAAI,QAAQ,KAAK;AAO1D,QAAM,QAAQE,QAA2B,KAAK,GAAG,EAAE,OAAO,MAAM,MAAM,CAAC;AAEvE,QAAM,OAAO,UAAU,UAAU,CAAC,MAAM,SAAS;AAChD,QAAI,UAAU;AACb,YAAM,SAAS,KAAK;AAKpB,UAAI,OAAO,GAAG,QAAQ,MAAM,CAAC,EAAG;AAEhC,YAAM,IAAI,MAAM;AAEhB;AAAA,IACD;AAEA,QAAI,CAAC,QAAQ,aAAa,MAAM,IAAI,GAAG;AAItC,cAAQ,cAAc,MAAM,IAAI;AAChC;AAAA,IACD;AAEA,UAAM,IAAI,KAAK,CAAC;AAAA,EACjB,CAAC;AACD,EAAAD,QAAOF,WAAU,EAAE,UAAU,IAAI;AAEjC,SAAO,MAAM,WAAW;AACzB;","names":["createLankaViewSubscription","DestroyRef","assertInInjectionContext","inject","signal"]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RenderComponentOptions, RenderResult } from '@testing-library/angular';
|
|
2
|
+
import { Type } from '@angular/core';
|
|
3
|
+
import { IPrepareLankaRenderOptions } from '@lankajs/tool-testing';
|
|
4
|
+
import { ILankaInstance } from 'lanka';
|
|
5
|
+
|
|
6
|
+
interface IRenderWithLankaOptions<TComponent> extends Omit<RenderComponentOptions<TComponent>, "wrapper">, IPrepareLankaRenderOptions {
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* What a render with a bootstrapped framework ADDS to the library's own result.
|
|
10
|
+
*
|
|
11
|
+
* An interface over the addition rather than over the whole result, and the same
|
|
12
|
+
* in all five bindings: Svelte Testing Library's result carries a string index
|
|
13
|
+
* signature for its bound queries, so a named member added by extension has to
|
|
14
|
+
* satisfy it — and `lanka` is an instance, not a query. Describing only the
|
|
15
|
+
* addition is true of every library and needs no cast anywhere.
|
|
16
|
+
*/
|
|
17
|
+
interface IRenderWithLankaResult {
|
|
18
|
+
/** The instance the render used. */
|
|
19
|
+
lanka: ILankaInstance;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Rendering an Angular component with a bootstrapped framework.
|
|
23
|
+
*
|
|
24
|
+
* ## Why
|
|
25
|
+
*
|
|
26
|
+
* A component reading a ViewModel needs a live instance: without one the first
|
|
27
|
+
* scenario or locator access fails. Assembling bootstrap in every component test
|
|
28
|
+
* is twenty lines of preamble that diverge between files silently.
|
|
29
|
+
*
|
|
30
|
+
* ## What is here, and what is in the kit
|
|
31
|
+
*
|
|
32
|
+
* The five bindings publish this name and differ only in which `render` they
|
|
33
|
+
* call. Everything else — a fresh instance, the doubles, the caller's setup and
|
|
34
|
+
* the scenario layer brought up in that order — is `prepareLankaRender` in
|
|
35
|
+
* `@lankajs/tool-testing`, which is the one place all five already look.
|
|
36
|
+
*
|
|
37
|
+
* ## Angular's is asynchronous, and that is its own
|
|
38
|
+
*
|
|
39
|
+
* Angular Testing Library drives `TestBed`, which COMPILES a component rather
|
|
40
|
+
* than merely mounting one, so `render` answers a promise. The four other
|
|
41
|
+
* bindings answer directly. That difference belongs to Angular's testing story
|
|
42
|
+
* and not to lanka, which is why it is not hidden behind a synchronous wrapper.
|
|
43
|
+
*/
|
|
44
|
+
declare const renderWithLanka: <TComponent>(component: Type<TComponent>, options?: IRenderWithLankaOptions<TComponent>) => Promise<RenderResult<TComponent, TComponent> & IRenderWithLankaResult>;
|
|
45
|
+
|
|
46
|
+
export { type IRenderWithLankaOptions, type IRenderWithLankaResult, renderWithLanka };
|