-
Notifications
You must be signed in to change notification settings - Fork 35
Fix intermittent happy-dom teardown race in JSX clean queue #337
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 all commits
7938c43
1901dc2
2a171f2
7755e44
5c5d50d
3c6a7e9
8230d3b
7400cf0
adb9256
aa62128
07cba32
cfcfccb
0314722
26f9e3a
200f8ae
22dad97
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 |
|---|---|---|
| @@ -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. |
| 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) | ||
| }) | ||
| }) | ||
| }) |
| 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() | ||
|
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) | ||
| } | ||
| } | ||
| } | ||
| 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
Contributor
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. 🧩 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' || trueRepository: 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 The new agent-facing 🤖 Prompt for AI Agents |
||
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:
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.jsRepository: knockout/tko
Length of output: 1405
🏁 Script executed:
Repository: knockout/tko
Length of output: 1820
Gate synchronous cleanup to happy-dom only.
The setup file unconditionally sets
jsxCleanBatchSize = 0for 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
🤖 Prompt for AI Agents