Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/fix-jsx-clean-teardown-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@tko/utils.jsx": patch
---

Add `options.jsxCleanBatchSize` (default `1000`) controlling JSX node cleanup
batching. Setting it to `0` runs cleanup synchronously on detach. Registered
via `defineOption` — the hardcoded `MAX_CLEAN_AT_ONCE` constant is gone, but
the new default matches it so production behavior is unchanged.

Fixes a `ReferenceError: Element is not defined` in the `cli-happy-dom` test
project where the 25ms batch timer could fire after happy-dom had torn down
DOM globals.
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ packages/example/
Inter-package dependencies use `@tko/package-name` and are resolved via
npm workspaces.

### Configurable runtime options (`ko.options.*`)

Register package-owned options via `defineOption` from `@tko/utils`, not as
fields on the core `Options` class. See
[`tko.io/public/agents/options.md`](tko.io/public/agents/options.md) for the
pattern and canonical example.

## CI/CD

GitHub Actions workflows (`.github/workflows/`):
Expand Down
9 changes: 9 additions & 0 deletions builds/knockout/helpers/vitest-setup.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as chai from 'chai'
import sinon from 'sinon'
import { options } from '@tko/utils'
import { isHappyDom } from '../../../packages/utils/helpers/test-env.ts'

// Set globals that builds/knockout specs and mocha-test-helpers.js expect
Expand All @@ -20,3 +21,11 @@ import '../dist/browser.min.js'
// Now import the helper — it needs chai, expect, ko, and beforeEach/afterEach as globals.
// beforeEach/afterEach come from vitest globals (globals: true in config).
import './mocha-test-helpers.js'

// Run JSX cleanup synchronously in tests. In happy-dom the 25ms batch
// timer can fire after the DOM globals are torn down, throwing from
// `cleanNode`. browser.min.js bundles its own Options instance, so mirror.
beforeAll(() => {
options.jsxCleanBatchSize = 0
globalThis.ko.options.jsxCleanBatchSize = 0
})
Comment on lines +28 to +31
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify which configs/suites load this setup file.
set -euo pipefail

rg -n -C3 'vitest-setup\.js|helpers/vitest-setup' .

Repository: knockout/tko

Length of output: 1415


🏁 Script executed:

# Read the vitest-setup.js file to confirm the actual code
cat -n builds/knockout/helpers/vitest-setup.js

Repository: knockout/tko

Length of output: 1405


🏁 Script executed:

# Check Vitest documentation patterns and environment detection in similar test configs
# Look for how other configs/tests detect happy-dom or NODE_ENV
rg -n 'process.env|happy-dom|NODE_ENV' builds/knockout vitest.config.ts

Repository: knockout/tko

Length of output: 1820


Gate synchronous cleanup to happy-dom only.

The setup file unconditionally sets jsxCleanBatchSize = 0 for both the browser real-browser matrix and the happy-dom environment. Since the teardown race is happy-dom-specific (line 25–27), the browser matrix should remain on production default batching behavior. isHappyDom() is already imported and available; use it to skip the sync cleanup setting except in happy-dom.

Suggested fix
 beforeAll(() => {
+  if (!isHappyDom()) return
   options.jsxCleanBatchSize = 0
   globalThis.ko.options.jsxCleanBatchSize = 0
 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeAll(() => {
options.jsxCleanBatchSize = 0
globalThis.ko.options.jsxCleanBatchSize = 0
})
beforeAll(() => {
if (!isHappyDom()) return
options.jsxCleanBatchSize = 0
globalThis.ko.options.jsxCleanBatchSize = 0
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@builds/knockout/helpers/vitest-setup.js` around lines 28 - 31, The beforeAll
setup currently forces synchronous cleanup by setting options.jsxCleanBatchSize
= 0 and globalThis.ko.options.jsxCleanBatchSize = 0 unconditionally; change it
to only perform those assignments when isHappyDom() returns true so real-browser
runs keep the production batching behavior—update the beforeAll block to check
isHappyDom() and only set options.jsxCleanBatchSize and
globalThis.ko.options.jsxCleanBatchSize inside that conditional.

125 changes: 125 additions & 0 deletions packages/utils.jsx/spec/jsxCleanBehaviors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { assert } from 'chai'
import sinon from 'sinon'

import { addDisposeCallback, options } from '@tko/utils'

import { queueCleanNode } from '../src/jsxClean'

describe('jsxClean queue', function () {
let cleaned: Node[]
let clock: sinon.SinonFakeTimers
let originalBatchSize: number

function makeNode(): HTMLElement {
const node = document.createElement('div')
addDisposeCallback(node, () => cleaned.push(node))
return node
}

beforeEach(function () {
cleaned = []
originalBatchSize = options.jsxCleanBatchSize
clock = sinon.useFakeTimers()
})

afterEach(function () {
clock.restore()
options.jsxCleanBatchSize = originalBatchSize
})

describe('jsxCleanBatchSize = 0 (synchronous)', function () {
beforeEach(function () {
options.jsxCleanBatchSize = 0
})

it('runs cleanup synchronously on queueCleanNode', function () {
const node = makeNode()
queueCleanNode(node)
assert.deepEqual(cleaned, [node])
})

it('drains all queued nodes synchronously in a single call', function () {
const nodes = [makeNode(), makeNode(), makeNode()]
for (const n of nodes) queueCleanNode(n)
assert.deepEqual(cleaned, nodes)
})

it('does not schedule a timer', function () {
queueCleanNode(makeNode())
assert.equal(clock.countTimers(), 0)
})
})

describe('jsxCleanBatchSize > 0 (batched, default 1000)', function () {
beforeEach(function () {
options.jsxCleanBatchSize = 1000
})

it('defers cleanup until the 25ms timer fires', function () {
const node = makeNode()
queueCleanNode(node)
assert.lengthOf(cleaned, 0, 'not cleaned before timer fires')
clock.tick(25)
assert.deepEqual(cleaned, [node])
})

it('does not re-trigger the timer while one is already pending', function () {
const a = makeNode()
const b = makeNode()
queueCleanNode(a)
queueCleanNode(b)
clock.tick(24)
assert.lengthOf(cleaned, 0)
clock.tick(1)
assert.deepEqual(cleaned, [a, b])
})

it('processes at most batchSize nodes per 25ms tick', function () {
options.jsxCleanBatchSize = 3
const nodes = [makeNode(), makeNode(), makeNode(), makeNode(), makeNode()]
for (const n of nodes) queueCleanNode(n)

clock.tick(25)
assert.lengthOf(cleaned, 3, 'first tick processes one batch')

clock.tick(25)
assert.lengthOf(cleaned, 5, 'second tick drains the remainder')
})

it('re-schedules itself when the queue is non-empty after a batch', function () {
options.jsxCleanBatchSize = 2
const nodes = Array.from({ length: 5 }, makeNode)
for (const n of nodes) queueCleanNode(n)

clock.tick(25)
assert.lengthOf(cleaned, 2)
clock.tick(25)
assert.lengthOf(cleaned, 4)
clock.tick(25)
assert.lengthOf(cleaned, 5)
})

it('drains synchronously if batchSize is flipped to 0 while a timer is pending', function () {
const nodes = [makeNode(), makeNode(), makeNode()]
for (const n of nodes) queueCleanNode(n)
assert.equal(clock.countTimers(), 1)

options.jsxCleanBatchSize = 0
clock.tick(25)

assert.deepEqual(cleaned, nodes)
assert.equal(clock.countTimers(), 0, 'no re-armed timer')
})

it('drains synchronously if batchSize becomes non-finite while a timer is pending', function () {
const nodes = [makeNode(), makeNode()]
for (const n of nodes) queueCleanNode(n)

options.jsxCleanBatchSize = Number.NaN as unknown as number
clock.tick(25)

assert.deepEqual(cleaned, nodes)
assert.equal(clock.countTimers(), 0)
})
})
})
52 changes: 44 additions & 8 deletions packages/utils.jsx/src/jsxClean.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,62 @@
import { cleanNode } from '@tko/utils'
import { cleanNode, defineOption, options } from '@tko/utils'

const DELAY_MS = 25
const MAX_CLEAN_AT_ONCE = 1000
const cleanNodeQueue = new Array()
let cleanNodeTimeoutID: ReturnType<typeof setTimeout> | null = null

// Extend the Options type so ko.options.jsxCleanBatchSize is strongly typed.
declare module '@tko/utils' {
interface Options {
jsxCleanBatchSize: number
}
}

// `0` runs cleanup synchronously on detach. Test environments that tear
// down DOM globals between files use that to avoid a pending 25ms timer
// firing against a dead global.
defineOption('jsxCleanBatchSize', { default: 1000 })

export function queueCleanNode(node) {
cleanNodeQueue.push(node)
triggerCleanTimeout()
if (options.jsxCleanBatchSize === 0) {
flushAll()
} else {
scheduleBatch()
}
}

function triggerCleanTimeout() {
function scheduleBatch() {
if (!cleanNodeTimeoutID && cleanNodeQueue.length) {
cleanNodeTimeoutID = setTimeout(flushCleanQueue, DELAY_MS)
cleanNodeTimeoutID = setTimeout(flushBatch, DELAY_MS)
}
}

function flushCleanQueue() {
function flushBatch() {
cleanNodeTimeoutID = null
const nodes = cleanNodeQueue.splice(0, MAX_CLEAN_AT_ONCE)
// If the option was flipped to a non-positive / non-finite value while the
// timer was pending, fall through to synchronous drain — otherwise
// splice(0, <=0|NaN) removes nothing and scheduleBatch re-arms forever.
const batchSize = Math.trunc(options.jsxCleanBatchSize)
if (!Number.isFinite(batchSize) || batchSize <= 0) {
flushAll()
return
}
const nodes = cleanNodeQueue.splice(0, batchSize)
for (const node of nodes) {
cleanNode(node)
}
triggerCleanTimeout()
scheduleBatch()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment on lines +34 to +49

function flushAll() {
if (cleanNodeTimeoutID !== null) {
clearTimeout(cleanNodeTimeoutID)
cleanNodeTimeoutID = null
}
// Outer `while` is for re-enqueues triggered by cleanNode side effects.
while (cleanNodeQueue.length) {
for (const node of cleanNodeQueue.splice(0)) {
cleanNode(node)
}
}
}
2 changes: 2 additions & 0 deletions tko.io/public/agents/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Test-backed behavior summaries live under `/agents/verified-behaviors/`. Treat t

For preferred state/binding/DOM architecture in examples and prototypes, read `/agents/contract.md`.

For how to add or configure runtime options (`ko.options.*`), read `/agents/options.md`.

## Setup

```html
Expand Down
66 changes: 66 additions & 0 deletions tko.io/public/agents/options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# `ko.options.*` — Configurable Runtime Options

`ko.options` is TKO's runtime configuration object. It is a singleton `Options`
instance defined in `packages/utils/src/options.ts`. This page documents how
to add new options and which of the two available mechanisms to reach for.

## Two mechanisms — when to use which

### `defineOption` (default — almost always use this)

For options that belong to a specific package (batch sizes, feature toggles,
plugin behavior), register via `defineOption` inside the owning package.

```ts
// packages/utils.jsx/src/jsxClean.ts
import { defineOption, options } from '@tko/utils'

// Extend the Options type so ko.options.<name> is strongly typed.
declare module '@tko/utils' {
interface Options {
jsxCleanBatchSize: number
}
}

// Register at module load, with an optional side-effect setter.
defineOption('jsxCleanBatchSize', { default: 1000 })

// Read wherever the option applies.
if (options.jsxCleanBatchSize === 0) { /* sync path */ }
```

Rules:

- The `declare module '@tko/utils' { interface Options { ... } }` augmentation
lives in the same file (or at least the same package) that defines the option.
- `defineOption` registers at module-side-effect time; the option is available
as soon as the owning package is imported.
- An optional `set(value)` runs side effects at configuration time (not
retroactively on already-parsed bindings) — use for options that swap
implementations or rebuild internal state.

**Canonical example with a side-effect setter:**
`packages/utils.parser/src/operators.ts` → `strictEquality` swaps the `==`
and `!=` operator implementations when the option flips.

### Core `Options` class field (rare — only for `@tko/utils` intrinsics)

Add a field directly to the `Options` class in `packages/utils/src/options.ts`
**only** when the option is intrinsic to `@tko/utils` itself — something that
`@tko/utils` uses or enforces without knowing about any downstream package.

Current core fields include `templateSizeLimit`, `deferUpdates`,
`useOnlyNativeEvents`, `onError`, `sanitizeHtmlTemplate`.

Never add a core-class field for an option that belongs to another package.
Doing so forces `@tko/utils` to carry concepts from downstream packages and
creates a wrong-way dependency.

## Quick test

> Is the option intrinsic to `@tko/utils`?

- Yes → core class field.
- No → `defineOption` in the owning package.

In practice, new options are almost always the second case.
Comment on lines +1 to +66
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that the new options documentation is discoverable from agent entrypoints
# and mirrored somewhere in the human-facing docs.
set -euo pipefail

printf '\nReferences to options.md / jsxCleanBatchSize / defineOption:\n'
fd -e md -e mdx -e astro . tko.io AGENTS.md 2>/dev/null \
  | xargs rg -n -C2 'options\.md|jsxCleanBatchSize|defineOption|ko\.options' || true

Repository: knockout/tko

Length of output: 6411


🏁 Script executed:

# Check agent entrypoints for references to options.md
echo "=== llms.txt content ===" 
cat tko.io/public/llms.txt 2>/dev/null || echo "File not found"

echo -e "\n=== agents/guide.md content ===" 
cat tko.io/public/agents/guide.md 2>/dev/null || echo "File not found"

Repository: knockout/tko

Length of output: 12319


🏁 Script executed:

# Search for Starlight docs that document option registration or defineOption pattern
fd -e md -e mdx . tko.io/src/content/docs 2>/dev/null \
  | xargs rg -l 'defineOption|option.*registration|ko\.options.*register' || echo "No Starlight pages found documenting option registration"

Repository: knockout/tko

Length of output: 112


Link new options page from agent discovery and add Starlight documentation for the defineOption pattern.

The new agent-facing /agents/options.md page documents defineOption (a new API pattern), but it's missing from llms.txt and agents/guide.md, making it undiscoverable. Per the coding guideline, new APIs and patterns must be documented in both Starlight (for human developers) and the agent guide. Add /agents/options.md to the agent discovery list in llms.txt, link it from agents/guide.md, and create a Starlight page documenting how developers add new options via defineOption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tko.io/public/agents/options.md` around lines 1 - 66, Add the new
agent-facing /agents/options.md into the agent discovery list (append its entry
to llms.txt) and insert a link to it from agents/guide.md; also create a
Starlight developer doc that documents the defineOption pattern including usage
examples (showing defineOption registration and reading ko.options.*), the
Options class core-field rule, and mention when to prefer core Options fields vs
defineOption (reference symbols: defineOption, Options class, ko.options.*).
Ensure the Starlight page explains the declare module augmentation and the
optional set(value) side-effect behavior so developers can follow the exact
pattern used in the diff.

1 change: 1 addition & 0 deletions tko.io/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- /agents/glossary.md — domain terms, concepts, packages
- /agents/testing.md — how to run and verify TKO code
- /agents/contract.md — state/binding/DOM architecture
- /agents/options.md — ko.options.* — when to use defineOption vs core Options fields
- /agents/verified-behaviors/index.md — test-backed behavior contracts
- /agents/sample-tsx.html — minimal browser TSX scaffold
- /examples/ — interactive self-contained HTML examples
Expand Down
Loading