-
Notifications
You must be signed in to change notification settings - Fork 35
docs(agents): add KO/ViewComponent guidelines from review #348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
d7d76d4
ea15802
5536845
9906ffb
c9a9f49
4adc87d
10a08a4
92999a2
5eeb04f
5f9ad79
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -33,6 +33,13 @@ ko.when(viewModel.isReady, () => console.log('Ready')) | |||||
| ko.when(() => viewModel.isReady() && viewModel.hasData()).then(() => console.log('Ready')) | ||||||
| ``` | ||||||
|
|
||||||
| ### Observables vs computed dependency management | ||||||
|
|
||||||
| - Bare `ko.observable()` and `ko.observableArray()` are safe anywhere — they are containers and do not re-evaluate. | ||||||
| - Inside a class using the `LifeCycle` mixin (for example `BindingHandler`, `ComponentABC`, or anything produced by `LifeCycle.mixInto`), prefer `this.computed(...)` over standalone `ko.computed(...)` and `this.subscribe(observable, cb)` over `observable.subscribe(cb)` — both are auto-disposed when the instance is torn down. | ||||||
| - In plain view models without `LifeCycle`, `ko.computed(...)` and `observable.subscribe(...)` are fine; call `dispose()` on them when you are done. | ||||||
| - **Never** create `ko.observable()`, `ko.computed()`, or call `.subscribe()` inside a computed's evaluator. The evaluator runs on every dependency change, so new observables/subscriptions pile up and never dispose — memory and CPU grow unbounded. Create them once outside the computed, or in a `LifeCycle`-enabled constructor. | ||||||
|
|
||||||
| ## Bindings | ||||||
|
|
||||||
| Activate with `ko.applyBindings(viewModel, element)`. | ||||||
|
|
@@ -65,6 +72,63 @@ When the goal is to demonstrate TKO itself, keep the state flow inside observabl | |||||
| - If an example contrasts reactive models, the counters and highlighted state should also be observable-driven so the example demonstrates the pattern instead of bypassing it. | ||||||
| - The line to avoid is using the DOM itself as the mutable source of truth after bindings are active. | ||||||
|
|
||||||
| ### Computed styling via attributes | ||||||
|
|
||||||
| Do not use computeds to switch CSS class names or inline style strings. Drive a computed attribute (typically `data-*`) and let CSS select on it. | ||||||
|
|
||||||
| ```tsx | ||||||
| // Bad: computed className | ||||||
| const className = this.computed(() => this.isActive() ? classes.active : classes.inactive) | ||||||
| return <div class={className}>...</div> | ||||||
|
|
||||||
| // Bad: computed inline style | ||||||
| const style = this.computed(() => this.isExpanded() ? 'display: block' : 'display: none') | ||||||
| return <div style={style}>...</div> | ||||||
|
|
||||||
| // Good: computed attribute + CSS selector | ||||||
| const isActive = this.computed(() => this.isActive() || undefined) | ||||||
| return <div data-active={isActive}>...</div> | ||||||
| ``` | ||||||
|
|
||||||
| ```css | ||||||
| .my-component { opacity: 0.5; } | ||||||
| .my-component[data-active] { opacity: 1; } | ||||||
| ``` | ||||||
|
|
||||||
| This keeps styling logic in CSS, avoids class-name string juggling inside computeds, and pairs naturally with the `|| undefined` pattern for binary attributes (see Gotchas). For classic `data-bind`, the equivalent writer is the `attr` binding: `data-bind="attr: { 'data-active': isActive }"`. | ||||||
|
|
||||||
| ### JSX scope rule | ||||||
|
|
||||||
| Use JSX inside component view models (classes registered with `components.register(...)`, typically extending `ComponentABC`) or in functions whose output is immediately passed to `tko.jsx.render(...)` and mounted. Do not leave JSX in standalone utility functions that outlive a component lifecycle — without `LifeCycle`, subscriptions and computeds created for that JSX have no owner to dispose them. | ||||||
|
|
||||||
| ```tsx | ||||||
| // Good: component owns the JSX and its lifecycle | ||||||
| class MyCard extends ComponentABC { | ||||||
| template = () => <div ko-text={this.message} /> | ||||||
| } | ||||||
|
|
||||||
| // Also fine: top-level render for a fixed mount | ||||||
| const { node } = tko.jsx.render(<div ko-text={message} />) | ||||||
| document.getElementById('root').appendChild(node) | ||||||
|
|
||||||
| // Avoid: JSX returned from a utility module without a clear owner | ||||||
| export const myView = () => <div ko-text={message} /> | ||||||
| ``` | ||||||
|
|
||||||
| ### Component params in JSX | ||||||
|
|
||||||
| When instantiating a component from JSX, pass each constructor parameter as its own attribute. Do **not** wrap them in a single `params={{ ... }}` attribute — JSX collects attributes into one object and hands that object to the component constructor, so wrapping in `params` delivers `{ params: { … } }` instead of the flat shape the constructor expects. | ||||||
|
|
||||||
| ```tsx | ||||||
| // Bad — wrapped in params | ||||||
| <my-report-card params={{ output, onView, onEdit }} /> | ||||||
|
|
||||||
| // Good — each prop is its own attribute | ||||||
| <my-report-card output={output} onView={onView} onEdit={onEdit} /> | ||||||
| ``` | ||||||
|
|
||||||
| The constructor receives `{ output, onView, onEdit }` directly. | ||||||
|
|
||||||
| ## Classic data-bind parsing and CSP | ||||||
|
|
||||||
| Classic `data-bind` parsing is provider-driven. Use `DataBindProvider` when you need binding strings, and combine it with other providers through `MultiProvider` as needed. | ||||||
|
|
@@ -222,6 +286,9 @@ tko.applyBindings({ removeTodo: t => todos.remove(t) }, root) | |||||
| - **Observable children in JSX** are reactive — when an observable's value changes, the DOM updates. If it becomes `undefined`, a placeholder comment is rendered. | ||||||
| - **Cannot apply bindings twice** to the same DOM node — throws error. | ||||||
| - **`dispose()` on computed/subscriptions** stops updates. After disposal, reading returns last cached value. | ||||||
| - **HTML-producing computeds** should return `null` for empty output, not `false` or an empty fragment — bindings and JSX observers handle `null` cleanly. | ||||||
| - **Binary HTML attributes** (`disabled`, `readonly`, `hidden`, `required`, `checked`, `selected`) omit the attribute when the value is `null`, `undefined`, or `false`. Use `|| undefined` in computeds to make "no attribute" explicit: `disabled={this.computed(() => shouldBeDisabled() || undefined)}`. Never return the string `"false"` — it keeps the attribute set. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This gotcha states that binary attributes are omitted when the value is Useful? React with 👍 / 👎.
|
||||||
| - **Binary HTML attributes** (`disabled`, `readonly`, `hidden`, `required`, `checked`, `selected`) omit the attribute when the value is `null`, `undefined`, or `false`. Use `|| undefined` in computeds to make "no attribute" explicit: `disabled={this.computed(() => shouldBeDisabled() || undefined)}`. Never return the string `"false"` — it keeps the attribute set. | |
| - **Binary HTML attributes** (`disabled`, `readonly`, `hidden`, `required`, `checked`, `selected`) should return `undefined` when you want JSX to remove the attribute. Use `|| undefined` in computeds to make "no attribute" explicit: `disabled={this.computed(() => shouldBeDisabled() || undefined)}`. Do not rely on `null` or `false` to omit the attribute, and never return the string `"false"` — it keeps the attribute set. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -30,3 +30,22 @@ Read this when you need test-backed behavior for `@tko/computed`, especially `co | |||||||||||||||||||||||||||||||||||||||
| - `when(predicate, callback)` runs the callback once, then disposes its subscription. | ||||||||||||||||||||||||||||||||||||||||
| Notes: The predicate may be either a function or an observable. The return value exposes `dispose()` to cancel the pending notification. With deferred updates enabled, the callback runs in a later task. | ||||||||||||||||||||||||||||||||||||||||
| Specs: `packages/computed/spec/observableUtilsBehaviors.ts`, `packages/computed/spec/asyncBehaviors.ts` | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| ## Anti-patterns | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| - Creating observables, computeds, or subscriptions **inside** a computed's evaluator leaks instances. The evaluator re-runs on every dependency change, producing a new un-disposed subscriber each time, so memory and subscriber count grow without bound. | ||||||||||||||||||||||||||||||||||||||||
| Test sketch: | ||||||||||||||||||||||||||||||||||||||||
| ```ts | ||||||||||||||||||||||||||||||||||||||||
| const dep = ko.observable(0) | ||||||||||||||||||||||||||||||||||||||||
| const instances: KnockoutObservable<number>[] = [] | ||||||||||||||||||||||||||||||||||||||||
| const leaky = ko.computed(() => { | ||||||||||||||||||||||||||||||||||||||||
| const obs = ko.observable(dep() + 1) | ||||||||||||||||||||||||||||||||||||||||
| instances.push(obs) | ||||||||||||||||||||||||||||||||||||||||
| return obs() | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| expect(instances.length).toBe(1) | ||||||||||||||||||||||||||||||||||||||||
| dep(1); expect(instances.length).toBe(2) | ||||||||||||||||||||||||||||||||||||||||
| dep(2); expect(instances.length).toBe(3) | ||||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||||
| Fix: create the observable/subscription once outside the computed, or inside a `LifeCycle` subclass constructor where `this.subscribe` / `this.computed` own disposal. | ||||||||||||||||||||||||||||||||||||||||
| Related specs: `packages/lifecycle/spec/LifeCycleBehaviors.ts` (disposal semantics). | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
| ## Anti-patterns | |
| - Creating observables, computeds, or subscriptions **inside** a computed's evaluator leaks instances. The evaluator re-runs on every dependency change, producing a new un-disposed subscriber each time, so memory and subscriber count grow without bound. | |
| Test sketch: | |
| ```ts | |
| const dep = ko.observable(0) | |
| const instances: KnockoutObservable<number>[] = [] | |
| const leaky = ko.computed(() => { | |
| const obs = ko.observable(dep() + 1) | |
| instances.push(obs) | |
| return obs() | |
| }) | |
| expect(instances.length).toBe(1) | |
| dep(1); expect(instances.length).toBe(2) | |
| dep(2); expect(instances.length).toBe(3) | |
| ``` | |
| Fix: create the observable/subscription once outside the computed, or inside a `LifeCycle` subclass constructor where `this.subscribe` / `this.computed` own disposal. | |
| Related specs: `packages/lifecycle/spec/LifeCycleBehaviors.ts` (disposal semantics). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # Verified Behaviors: @tko/debug | ||
|
|
||
| > Generated from package discovery plus package-local curated unit-test-backed JSON. | ||
| > If a behavior is not covered by unit tests, it does not belong in this directory. | ||
|
|
||
| Verified behaviors for @tko/debug. | ||
|
|
||
| ## When to Read This | ||
|
|
||
| Read this when you need test-backed behavior for `@tko/debug`, especially verified behaviors for @tko/debug. | ||
|
|
||
| ## Status | ||
|
|
||
| - Status: no-tests-found | ||
| - Summary: No tests found for this package. | ||
|
|
||
| ## Next Step | ||
|
|
||
| Add tests first, then publish curated verified behaviors once the behavior contract is covered. |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -28,3 +28,9 @@ Read this when you need test-backed behavior for `@tko/lifecycle`, especially li | |||||||||||||
| Specs: `packages/lifecycle/spec/LifeCycleBehaviors.ts` | ||||||||||||||
| - Anchoring one lifecycle object to another with `anchorTo(...)` causes disposal of the parent lifecycle to dispose the anchored child as well. | ||||||||||||||
| Specs: `packages/lifecycle/spec/LifeCycleBehaviors.ts` | ||||||||||||||
|
|
||||||||||||||
| ## Usage guidance | ||||||||||||||
|
|
||||||||||||||
| - Prefer `this.computed(...)` over standalone `ko.computed(...)` inside a `LifeCycle` subclass — the computed is added to the instance's disposal set automatically. | ||||||||||||||
| - Prefer `this.subscribe(observable, callback)` over `observable.subscribe(callback)` inside a `LifeCycle` subclass for the same reason. | ||||||||||||||
| - Do not create observables, computeds, or subscriptions inside a computed's evaluator — see the anti-pattern in `computed.md`. | ||||||||||||||
|
||||||||||||||
| ## Usage guidance | |
| - Prefer `this.computed(...)` over standalone `ko.computed(...)` inside a `LifeCycle` subclass — the computed is added to the instance's disposal set automatically. | |
| - Prefer `this.subscribe(observable, callback)` over `observable.subscribe(callback)` inside a `LifeCycle` subclass for the same reason. | |
| - Do not create observables, computeds, or subscriptions inside a computed's evaluator — see the anti-pattern in `computed.md`. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -30,3 +30,5 @@ Read this when you need test-backed behavior for `@tko/utils.jsx`, especially JS | |||||
| Specs: `packages/utils.jsx/spec/jsxBehaviors.ts` | ||||||
| - JSX-created nodes can carry native bindings and participate in component rendering and observable-array diff updates. | ||||||
| Specs: `packages/utils.jsx/spec/jsxBehaviors.ts` | ||||||
| - Binary HTML attributes (`disabled`, `readonly`, `hidden`, `required`, `checked`, `selected`) omit the attribute when the observable/computed value is `null`, `undefined`, or `false`; any other value (including the string `"false"`) sets the attribute. Use `|| undefined` in computeds to express "no attribute" explicitly. | ||||||
|
||||||
| - Binary HTML attributes (`disabled`, `readonly`, `hidden`, `required`, `checked`, `selected`) omit the attribute when the observable/computed value is `null`, `undefined`, or `false`; any other value (including the string `"false"`) sets the attribute. Use `|| undefined` in computeds to express "no attribute" explicitly. | |
| - Binary HTML attributes (`disabled`, `readonly`, `hidden`, `required`, `checked`, `selected`) omit the attribute only when the observable/computed value is `undefined`; `null` and `false` are stringified and set, so even `disabled="false"` still leaves the attribute present. Use `|| undefined` in computeds, or an explicit conditional, to express "no attribute" explicitly. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify binary HTML attribute rendering behavior with null/false values
echo "=== Searching for binary attribute preprocessing logic ==="
rg -n -C5 'disabled|readonly|hidden|required|checked|selected' packages/utils.jsx/src/ --type ts
echo ""
echo "=== Searching for null/false to undefined conversion ==="
rg -n -C5 'null.*undefined|false.*undefined' packages/utils.jsx/src/JsxObserver.ts
echo ""
echo "=== Looking for attribute value normalization ==="
ast-grep --pattern $'function $FUNC($$$) {
$$$
if ($VALUE === null || $VALUE === false) {
$$$
}
$$$
}'Repository: knockout/tko
Length of output: 536
🏁 Script executed:
# Find and examine JsxObserver.ts file
find . -name "JsxObserver.ts" -type fRepository: knockout/tko
Length of output: 95
🏁 Script executed:
# Read the utils.jsx spec file to understand expected behavior
find . -path "*/packages/utils.jsx/spec/jsxBehaviors.ts" -type fRepository: knockout/tko
Length of output: 97
🏁 Script executed:
# Read the documentation file being reviewed
find . -path "*/tko.io/public/agents/verified-behaviors/utils-jsx.md" -type fRepository: knockout/tko
Length of output: 110
🏁 Script executed:
# Check sizes of the files
wc -l ./packages/utils.jsx/src/JsxObserver.ts ./packages/utils.jsx/spec/jsxBehaviors.ts ./tko.io/public/agents/verified-behaviors/utils-jsx.mdRepository: knockout/tko
Length of output: 222
🏁 Script executed:
# Read JsxObserver.ts around lines 398-408
sed -n '390,420p' ./packages/utils.jsx/src/JsxObserver.tsRepository: knockout/tko
Length of output: 1120
🏁 Script executed:
# Read the spec file
cat ./packages/utils.jsx/spec/jsxBehaviors.tsRepository: knockout/tko
Length of output: 37565
🏁 Script executed:
# Read the documentation file
cat ./tko.io/public/agents/verified-behaviors/utils-jsx.mdRepository: knockout/tko
Length of output: 2236
🏁 Script executed:
# Search for binary attribute tests in the spec file
grep -n "disabled\|readonly\|hidden\|required\|checked\|selected" ./packages/utils.jsx/spec/jsxBehaviors.tsRepository: knockout/tko
Length of output: 38
🏁 Script executed:
# Search for tests that check attribute removal with null/false
grep -n -B3 -A3 "null\|false" ./packages/utils.jsx/spec/jsxBehaviors.ts | grep -A5 -B5 "attribute"Repository: knockout/tko
Length of output: 1683
🏁 Script executed:
# Search for where setNodeAttribute is called
grep -n "setNodeAttribute" ./packages/utils.jsx/src/JsxObserver.tsRepository: knockout/tko
Length of output: 434
🏁 Script executed:
# Look for NativeProvider.addValueToNode to see if it does preprocessing
find . -name "*NativeProvider*" -type fRepository: knockout/tko
Length of output: 163
🏁 Script executed:
# Read NativeProvider.ts to see if addValueToNode handles binary attributes
cat ./packages/provider.native/src/NativeProvider.tsRepository: knockout/tko
Length of output: 2203
🏁 Script executed:
# Get more context around setNodeAttribute calls
sed -n '360,380p' ./packages/utils.jsx/src/JsxObserver.tsRepository: knockout/tko
Length of output: 663
🏁 Script executed:
# Search for "binary" in the codebase to find relevant code
rg -n "binary" --type tsRepository: knockout/tko
Length of output: 361
🏁 Script executed:
# Read the full JsxObserver.ts to understand the attribute handling flow
cat ./packages/utils.jsx/src/JsxObserver.tsRepository: knockout/tko
Length of output: 13764
🏁 Script executed:
# Search spec file for tests on binary attributes like "disabled", "checked", etc.
grep -n "disabled\|checked\|readonly\|hidden\|required\|selected" ./packages/utils.jsx/spec/jsxBehaviors.tsRepository: knockout/tko
Length of output: 38
🏁 Script executed:
# Search for any tests involving null or false on attributes
grep -n -B5 -A10 "attributes.*null\|attributes.*false" ./packages/utils.jsx/spec/jsxBehaviors.tsRepository: knockout/tko
Length of output: 38
🏁 Script executed:
# Look for tests that verify attribute removal
grep -n -B3 -A3 "removeAttribute" ./packages/utils.jsx/spec/jsxBehaviors.tsRepository: knockout/tko
Length of output: 38
🏁 Script executed:
# Let's verify the documented behavior by checking the verified-behaviors.json file
find . -name "verified-behaviors.json" -path "*/utils.jsx/*" -type fRepository: knockout/tko
Length of output: 100
🏁 Script executed:
# Let's also test the actual behavior by creating a small test to see what happens
# Read any existing attribute-related test to understand how they test attribute behavior
grep -n "attributes:" ./packages/utils.jsx/spec/jsxBehaviors.ts | head -20Repository: knockout/tko
Length of output: 1720
🏁 Script executed:
# Look for the test that verifies toggles observable attribute
grep -n -B2 -A8 "toggles an observable attribute" ./packages/utils.jsx/spec/jsxBehaviors.tsRepository: knockout/tko
Length of output: 479
🏁 Script executed:
# Read the verified-behaviors.json file
cat ./packages/utils.jsx/verified-behaviors.jsonRepository: knockout/tko
Length of output: 1564
🏁 Script executed:
# Check the test that toggles empty observable attribute (just before the one we found)
grep -n -B2 -A8 "toggles an empty observable attribute" ./packages/utils.jsx/spec/jsxBehaviors.tsRepository: knockout/tko
Length of output: 469
Fix documented binary HTML attribute behavior or update documentation to match implementation.
The documentation states that binary HTML attributes (disabled, readonly, hidden, required, checked, selected) omit the attribute when the value is null, undefined, or false. However, the code in JsxObserver.ts:398-410 only removes attributes when value === undefined. For null and false values, the code calls node.setAttributeNS(ns, name, String(value)), which renders "null" and "false" as attribute values rather than omitting them.
Additionally, this behavior is not covered by any tests in packages/utils.jsx/spec/jsxBehaviors.ts and is not included in packages/utils.jsx/verified-behaviors.json (which is generated from unit tests). Either implement the documented behavior with proper preprocessing to convert null and false to undefined for binary attributes, or update the documentation to reflect the actual implementation that only omits attributes for undefined values.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tko.io/public/agents/verified-behaviors/utils-jsx.md` around lines 33 - 34,
The docs say binary attributes (disabled, readonly, hidden, required, checked,
selected) should be omitted for null/undefined/false, but JsxObserver currently
only omits on undefined; update the attribute-update logic in JsxObserver.ts
(the function handling setAttributeNS/attribute writes around the
node.setAttributeNS(ns, name, String(value)) calls) to treat null and false the
same as undefined by normalizing value => undefined for these specific attribute
names before calling setAttributeNS or removeAttribute, add unit tests in
packages/utils.jsx/spec/jsxBehaviors.ts to cover null and false cases, and
regenerate packages/utils.jsx/verified-behaviors.json so the verified behaviors
match the new behavior.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -46,6 +46,9 @@ | |||||
| - Derived `ko-*` values must stay observable/computed — inline expressions freeze | ||||||
| - `ko.applyBindings(...)` returns a Promise | ||||||
| - Inside `ko-foreach`, binding-context vars use strings: `ko-text="$data"` (not `{$data}`) | ||||||
| - Component JSX params: pass each constructor param as its own attribute (`<my-card out={x} onEdit={fn} />`), never wrap in `params={{...}}` — JSX attrs flatten into one constructor arg, so wrapping double-nests to `{ params: {...} }` | ||||||
| - Never create `ko.observable()`, `ko.computed()`, or `.subscribe()` inside a computed evaluator — re-runs leak instances | ||||||
|
||||||
| - Never create `ko.observable()`, `ko.computed()`, or `.subscribe()` inside a computed evaluator — re-runs leak instances | |
| - Avoid creating `ko.observable()`, `ko.computed()`, or `.subscribe()` inside a computed evaluator — `ko.computed()` and `.subscribe()` can accumulate unless disposed, while `ko.observable()` usually just creates unnecessary transient instances unless references are retained |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This section states that creating
ko.observable()inside a computed evaluator causes instances to pile up and never dispose. In@tko/computed, dependency tracking disposes subscriptions to prior dependencies on each evaluation, so creating an observable inside an evaluator doesn't inherently leak unless you retain references or create unmanaged subscriptions/computeds. Please narrow this guidance to the cases that actually leak (e.g., creating subscriptions or computeds inside the evaluator) or reword it as a performance/clarity anti-pattern rather than an unbounded leak for plain observables.