Skip to content
Merged
74 changes: 74 additions & 0 deletions tko.io/public/agents/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,80 @@ In those cases:
- let the custom binding read observables and update the DOM
- avoid making the custom binding the authoritative owner of app state

## DOM Mutation Containment

DOM mutation and direct DOM-API calls belong inside a `BindingHandler` (a class that extends `LifeCycle` and is registered as a binding). They do not belong in component view models, utility modules, or arbitrary class methods.

Violations (do not do outside a `BindingHandler`):
- `document.createElement`, `document.body.appendChild` — bypasses the binding pipeline
- `element.querySelector` / `querySelectorAll` — except to find a mount root for `applyBindings`
- `element.style.*`, `element.classList.*` — use the `style` / `css` bindings
- `element.addEventListener` — use `click`, `event:{…}`, or a binding handler
- `appendChild` / `insertBefore` / `replaceWith` — use `foreach`, `if`, `template`
- `element.innerHTML = …` — use `text` or (when trusted markup is the point) `html`; also flag XSS if content is external
- `element.focus()` / `element.select()` — use `hasFocus` or a focus-orchestration binding
- `requestAnimationFrame` for DOM scheduling — move inside a binding that owns the frame loop
- Storing `HTMLElement` references as component view-model fields — the element belongs to the binding

Where these calls are safe:
- inside a class that extends `BindingHandler` (its `constructor`/`init`/`update` receive the owning element)
- reading observables whose values drive DOM updates via bindings — that is the whole point
- direct DOM reads in tests/verification code after bindings are active

Binding handlers are the prescribed escape hatch for imperative DOM work: they receive the exact element, participate in the lifecycle, and dispose cleanly. Mutating the DOM from elsewhere creates a second source of truth for DOM state and the reactive graph loses sight of it.

## Component Design: Binding Handlers and Component View Models

### Binding handlers — narrow scope, one DOM task

Each `BindingHandler` subclass should do one thing to its element. A handler that sanitizes HTML *and* walks the DOM for citations *and* renders diagrams *and* rewrites tables should be split into separate handlers, each composable on the same element.

Violations:
- a binding handler performing multiple unrelated DOM operations — split by concern
- using a binding handler where a component with JSX would suffice — prefer declarative JSX when the rendering can be expressed that way

Not a violation:
- a handler that is complex because its single DOM task is inherently complex (rich-text editor, canvas renderer, third-party widget bridge)

### Component view models — rendering only

A component view model (a class registered with `components.register(...)`, typically extending `ComponentABC`) should contain only code that produces its template output. Data transformation, parsing, business rules, and utilities belong in standalone `.ts` files the component imports.

Violations:
- template method exceeding ~80 lines of JSX without decomposition — extract nested child components
- manipulating the DOM directly from a component method — delegate to a binding handler
- non-rendering logic (parsing, domain rules, formatting of structured data) embedded in the component — move to utilities
- instantiating a child component with `params={{ ... }}` wrapping every prop (see "Component Params in JSX" in `/agents/guide.md`)

Not a violation:
- a component that is large because it composes many small child components
- simple inline computeds that only map data for the template (`const label = ko.pureComputed(() => format(value()))`)

## Component Communication via `subscribable`

When a component needs to trigger an imperative action inside a binding handler (for example "print this iframe", "scroll to top", "focus on demand"), pass a `ko.subscribable` owned by the component into the handler. The handler subscribes in its constructor and disposes the subscription in `dispose`.

Do not model the command as an `observable` holding a function (`observable<(() => void) | null>(null)`). Function-valued observables capture closures that may retain DOM references, muddle cleanup, and do not broadcast.

```js
// Component owns the channel and fires events
class PrintableCard extends ComponentABC {
printChannel = new ko.subscribable()
onPrintClick = () => this.printChannel.notifySubscribers(null, 'print')
}

// Binding handler subscribes and disposes with its lifecycle
class PrintIframe extends BindingHandler {
constructor (params) {
super(params)
const channel = this.value
this.addDisposable(channel.subscribe(() => this.$element.contentWindow.print(), null, 'print'))
}
}
```

The element reference stays inside the binding handler, `dispose` cleans up naturally, and multiple elements can listen on the same channel without coordination.

## Security Preference

- Prefer `text` over `html`.
Expand Down
67 changes: 67 additions & 0 deletions tko.io/public/agents/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Apr 20, 2026

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.

Suggested change
- **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.
- Avoid creating reactive objects inside a computed's evaluator. Creating `ko.computed()` instances or calling `.subscribe()` there can accumulate unmanaged work unless you dispose them manually, because the evaluator runs on every dependency change. Creating a plain `ko.observable()` there does not inherently leak, but it is usually a performance/clarity anti-pattern unless it is purely temporary and you do not retain references to it. Prefer creating observables, computeds, and subscriptions once outside the evaluator, or in a `LifeCycle`-enabled constructor.

Copilot uses AI. Check for mistakes.

## Bindings

Activate with `ko.applyBindings(viewModel, element)`.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Correct binary-attribute false/null semantics

This gotcha states that binary attributes are omitted when the value is null or false, but packages/utils.jsx/src/JsxObserver.ts removes attributes only for undefined and otherwise stringifies the value via setAttributeNS, so false/null become present attributes ("false"/"null"). For boolean HTML attrs like disabled, presence keeps the attribute active, so code generated from this guidance can leave controls disabled when the computed returns false; please align this rule (and the copied wording in verified-behaviors/utils-jsx.md) with actual runtime behavior.

Useful? React with 👍 / 👎.

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Gotcha says binary HTML attributes are omitted when the value is null, undefined, or false, but JSX attribute setting currently only removes attributes when the value is undefined (other values, including null/false, are stringified and set). Please revise the wording to avoid implying that null/false automatically omit the attribute, and keep the guidance focused on returning undefined (e.g., via || undefined) to ensure removal.

Suggested change
- **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.

Copilot uses AI. Check for mistakes.
- **Prefer named computed variables over inline computeds in JSX.** A computed created inline in JSX is easier to accidentally recreate on each render than one bound to a class field or `const`.

## Testing

Expand Down
19 changes: 19 additions & 0 deletions tko.io/public/agents/verified-behaviors/computed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new "Anti-patterns" entry is in the "verified-behaviors" directory, but it doesn't cite a spec that actually verifies the described leak, and it includes a "test sketch" rather than a real test reference. Given the file header says non-test-backed behaviors do not belong here, either add/point to a concrete spec that covers this behavior (and cite it as "Specs:"), or move this guidance to /agents/guide.md (and keep this file strictly test-backed).

Suggested change
## 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).

Copilot uses AI. Check for mistakes.
19 changes: 19 additions & 0 deletions tko.io/public/agents/verified-behaviors/debug.md
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.
6 changes: 6 additions & 0 deletions tko.io/public/agents/verified-behaviors/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Usage guidance" section adds non-test-backed recommendations to a "verified-behaviors" doc whose header states that behaviors not covered by unit tests do not belong here. Please either (a) move this guidance into /agents/guide.md, or (b) explicitly connect each bullet to an existing spec (using the same "Specs:" convention as the rest of the file) so readers can verify the claim from tests.

Suggested change
## 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`.

Copilot uses AI. Check for mistakes.
2 changes: 2 additions & 0 deletions tko.io/public/agents/verified-behaviors/utils-jsx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claim that binary HTML attributes are omitted when the value is null or false doesn't match the current JSX implementation: JsxObserver.setNodeAttribute only removes attributes when the unwrapped value is undefined; null/false are stringified and set (e.g., disabled="false" still leaves the attribute present). Please update this bullet to reflect that omission only happens via undefined, and keep || undefined (or an explicit conditional) as the recommended way to remove the attribute.

Suggested change
- 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.

Copilot uses AI. Check for mistakes.
Specs: `packages/utils.jsx/spec/jsxBehaviors.ts`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 f

Repository: 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 f

Repository: 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 f

Repository: 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.md

Repository: knockout/tko

Length of output: 222


🏁 Script executed:

# Read JsxObserver.ts around lines 398-408
sed -n '390,420p' ./packages/utils.jsx/src/JsxObserver.ts

Repository: knockout/tko

Length of output: 1120


🏁 Script executed:

# Read the spec file
cat ./packages/utils.jsx/spec/jsxBehaviors.ts

Repository: knockout/tko

Length of output: 37565


🏁 Script executed:

# Read the documentation file
cat ./tko.io/public/agents/verified-behaviors/utils-jsx.md

Repository: 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.ts

Repository: 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.ts

Repository: knockout/tko

Length of output: 434


🏁 Script executed:

# Look for NativeProvider.addValueToNode to see if it does preprocessing
find . -name "*NativeProvider*" -type f

Repository: knockout/tko

Length of output: 163


🏁 Script executed:

# Read NativeProvider.ts to see if addValueToNode handles binary attributes
cat ./packages/provider.native/src/NativeProvider.ts

Repository: knockout/tko

Length of output: 2203


🏁 Script executed:

# Get more context around setNodeAttribute calls
sed -n '360,380p' ./packages/utils.jsx/src/JsxObserver.ts

Repository: knockout/tko

Length of output: 663


🏁 Script executed:

# Search for "binary" in the codebase to find relevant code
rg -n "binary" --type ts

Repository: 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.ts

Repository: 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.ts

Repository: 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.ts

Repository: 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.ts

Repository: 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 f

Repository: 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 -20

Repository: 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.ts

Repository: knockout/tko

Length of output: 479


🏁 Script executed:

# Read the verified-behaviors.json file
cat ./packages/utils.jsx/verified-behaviors.json

Repository: 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.ts

Repository: 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.

3 changes: 3 additions & 0 deletions tko.io/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Gotcha asserts that creating ko.observable() inside a computed evaluator leaks instances. @tko/computed disposes prior dependency subscriptions on each evaluation, so plain observables created inside the evaluator don't necessarily accumulate unless references are retained; the leak risk is much clearer for creating subscriptions/computeds inside the evaluator. Please adjust the wording to avoid stating a guaranteed leak for ko.observable() and focus on the cases that actually leak or on the performance implications.

Suggested change
- 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

Copilot uses AI. Check for mistakes.
- Binary HTML attrs (`disabled`, `hidden`, …): return `|| undefined` from computed; `false` renders the string `"false"` and keeps the attribute

## Browser JSX (esbuild-wasm)

Expand Down
Loading