GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — feat(reactive): add an opt-in glitch-free batched scheduler by JosunLP · Pull Request #242 · bQuery/bQuery Conversation
Collaborator
Summary
MotivationCloses #210. Reproduced exactly as reported: Changes included
Acceptance criteria
Validation
The performance claim in the issue, measured here. 1000 writes to one signal with 1000 subscribed effects, Bun 1.3.11:
Checklist
Notes for reviewersThe part that goes beyond the flag, and whyMicrotask batching alone was not enough, and writing the acceptance tests is what surfaced it. My own test caught this: const a = signal(1); const double = computed(() => a.value * 2); const quadruple = computed(() => double.value * 2); effect(() => seen.push([double.value, quadruple.value])); The effect saw So a flush now drains derivations to a fixed point before running any effect. One subtlety worth flagging, since I got it wrong on the first attempt: both of a computed's observers have to be marked. This ordering change applies to explicit Why reuse the batch machinery rather than write a new schedulerThe issue proposes a two-phase mark-dirty/flush design. Smaller notes
🤖 Generated with Claude Code https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz Generated by Claude Code
Sorry, something went wrong. Uh oh!
There was an error while loading. Please reload this page. All reactionsIn a diamond dependency graph `effect()` observed intermediate states that
never logically existed, firing once per upstream computed rather than once
per settled graph. Reproduced exactly as reported: `[1, 2, 4, 5, 7]` where
`[1, 4, 7]` is correct.
`configureReactive({ scheduler: 'batched' })` coalesces writes onto a
microtask, reusing the batch machinery that already made explicit `batch()`
glitch-free, so a burst in one tick produces one effect run. `flushSync()`
drains pending updates for tests and for code that needs synchronous
timing; inside a `batch()` it leaves the work with the enclosing batch.
`'sync'` stays the default for all of 1.x, since effect timing is
observable.
Measured on Bun 1.3.11 — 1000 writes to one signal with 1000 subscribed
effects: 1314 ms / 1,000,000 effect invocations under `'sync'`, 44 ms /
1000 under `'batched'`.
Microtask batching alone was not enough, and writing the acceptance tests
surfaced why. The pending-observer queue mixed computed revalidations with
effects in insertion order, so in a chain `a → double → quadruple` an
effect reading both could run between the two recomputations and see a
fresh `double` beside a stale `quadruple`. A flush now drains derivations
to a fixed point before running any effect. Both of a computed's observers
are marked — `markDirty`, which downstream computeds subscribe with, and
the `revalidate` it schedules; marking only one leaves the other queued
alongside effects, which is the ordering that caused the glitch.
That ordering fix applies to explicit `batch()` under the default
scheduler too, so it is a behaviour change outside the flag — a strictly
more correct one, and the existing suite passes unchanged.
Closes #210
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
|
There was an error while loading. Please reload this page.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
`test:types` builds the test tree against tsconfig.component-test.json, whose lib predates ES2022, so `order.at(-1)` fails with TS2550. Matches the existing precedent in c612ef4, which removed `Array.prototype.at` from the computed revalidation test for the same reason. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Reviewed the scheduler change closely, including the part flagged in "Notes for reviewers" (derivations drained to a fixed point before effects). The core design holds up — I could not break it.
What I verified locally on 1ab3030:
bun test — 3088 pass, 1 fail, and the failure is the known IPv6 createServer test, unrelated to this PR.bun run check — all five sub-checks clean, including check:doc-exports (reactive 40/40 mentioned).'batched' always converges to the 'sync' value, and no observation was ever internally inconsistent (b == a+1 && c == a*2 && d == b+c held on every recorded run).markDirty and revalidate is correct, and the classification is complete: Signal and Computed are the only ReactiveSource implementations and track() is only called from effect and Computed.recompute, so every unmarked pending observer really is an effect.dev: [51], this branch: [51, 52]); depth 60 hits the cap on both.flushSync inside batch, inside an effect, nested batches during a flush), and pendingObservers is always empty when a flush returns.Four findings below — one behavioural, two contract/robustness, one test name. None of them block the design; the first is the one I would most like addressed before merge.
On the judgement call you raised: making the derivation-first ordering apply to batch() under the default scheduler looks right to me. It strictly reduces what an effect can observe mid-graph, and it also removes a double-run (pre-PR an effect could run, mark the computed's notified-value baseline stale, and then be woken a second time by revalidate). Gating it behind the flag would keep a glitch inside a construct whose entire promise is that there isn't one.
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| * configureReactive({ scheduler: 'batched' }); | ||
| * ``` | ||
| */ | ||
| export const configureReactive = (options: Partial<ReactiveConfig>): void => { |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Switching back to 'sync' while work is queued replays an already-delivered notification.
configureReactive only narrows the flag; it does not drain pendingObservers, and the microtask queued by the earlier write still fires. Reproduced on this branch:
configureReactive({ scheduler: 'batched' }); const count = signal(0); const runs: number[] = []; effect(() => { runs.push(count.value); }); count.value = 1; // queued + microtask scheduled configureReactive({ scheduler: 'sync' }); count.value = 2; // runs synchronously -> [0, 2] await Promise.resolve(); // stale queued observer drains -> [0, 2, 2]
runs ends up [0, 2, 2]. The extra run carries no new information, but effects are not required to be idempotent — a POST, an analytics event, an animation start or a list append fires twice for one logical change.
The existing test (flushes work queued before a switch back to sync) only covers the case where no write follows the switch, so it passes either way. Draining before the switch would make the transition clean:
| export const configureReactive = (options: Partial<ReactiveConfig>): void => { | |
| export const configureReactive = (options: Partial<ReactiveConfig>): void => { | |
| if (options.scheduler !== undefined && options.scheduler !== config.scheduler) { | |
| // Hand over cleanly: anything queued under the outgoing scheduler is | |
| // delivered now, so the microtask cannot replay it after a later | |
| // synchronous write has already notified the same observers. | |
| flushSyncInternal(); | |
| config.scheduler = options.scheduler; | |
| } | |
| }; |
(that needs import { flushSyncInternal } from './internals', which would make config → internals → config a cycle; passing a drain callback in, or moving getScheduler next to the flush machinery, avoids it.)
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| * expect(rendered).toBe('1'); // no `await` needed | ||
| * ``` | ||
| */ | ||
| export const flushSync = (): void => { |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
flushSync() is also a silent no-op when called from inside an observer, which the doc comment does not cover.
flushSyncInternal is beginBatch(); endBatch();, and during a flush batchDepth is already 1, so the call just nests to 2 and back — nothing is flushed, and flushObservers would early-return on isFlushing anyway. Reproduced on this branch:
configureReactive({ scheduler: 'batched' }); effect(() => { seenB.push(b.value); }); effect(() => { if (a.value > 0) { b.value = a.value; flushSync(); // seenB is still [0] here — the dependent effect has not run } }); a.value = 5;
Right after flushSync() inside the effect, seenB === [0]; the same call outside an effect gives [0, 7]. The work does land later (the enclosing flush picks it up), so this is a contract problem rather than lost state: the JSDoc promises "run any pending reactive updates now" and only carves out batch(), so a caller inside an effect, a watch callback, or a component hook that runs inside an effect gets nothing and no diagnostic.
Worth either documenting the observer case alongside the batch() case, or (better) warning in dev when flushSync() is called re-entrantly, since the failure is invisible otherwise.
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| if (++derivationPasses > MAX_FLUSH_PASSES) { | ||
| warnUnsettled(); | ||
| break; | ||
| } |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
When the derivation cap trips, this break falls through into the effect phase and runs effects against a knowingly half-settled graph — the exact glitch the new ordering exists to prevent.
Two differences from the outer guard, which handles the same "did not settle" situation by pendingObservers.clear() + break out of flushObservers entirely:
pendingDerivations have already been removed from pendingObservers by takePending, so they are dropped unrun while the still-dirty computeds remain dirty — and then runObservers(takePending(() => true)) on line 189 runs every pending effect anyway, which is what pulls the stale values.derivationPasses resets on every outer pass, one flush can now do up to MAX_FLUSH_PASSES * MAX_FLUSH_PASSES (10 000) observer runs and emit up to 100 copies of the same warning, where the pre-PR code gave up after 100 passes and one warning.Suggest matching the outer guard's semantics — abort the whole flush instead of continuing to the effect phase:
| } | |
| if (++derivationPasses > MAX_FLUSH_PASSES) { | |
| pendingObservers.clear(); | |
| warnUnsettled(); | |
| return; | |
| } |
(return inside the try still runs the finally that resets isFlushing; a settled flag plus break on both loops reads fine too.)
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| expect(runs).toBe(4); // creation + one per tick | ||
| }); | ||
|
|
||
| it('does not run an effect at all when the value settles back', async () => { |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
The name says the opposite of what the test asserts: expect(runs).toBe(2) is creation + one run, so the effect does run when the value settles back. The body comment explains the real behaviour correctly, but the title is what a future maintainer will read as the spec — and "fixing" the scheduler to match the title (skip a queued observer whose value is unchanged at flush time) is a behaviour change this assertion actually forbids.
| it('does not run an effect at all when the value settles back', async () => { | |
| it('still runs the effect once when the value settles back within a tick', async () => { |
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Review findings on #210: - Switching back to `'sync'` while work was queued replayed an already delivered notification: the earlier write's microtask survived the switch and fired after a later synchronous write had notified the same observer, so one logical change ran the effect twice. `configureReactive` now drains before changing the flag. The drain is registered by `internals` rather than imported, since `internals` already imports `config`. - `flushSync()` was a silent no-op inside an observer, not just inside `batch()`: a flush is already running, so `beginBatch(); endBatch();` nests to depth 2 and back. The doc only carved out the `batch()` case, so a caller in an effect, a `watch` callback or a component hook got nothing and no diagnostic. It now warns, matching how the rest of this module reports an unsettled flush, and the contract is documented. - When the derivation cap tripped, the `break` dropped the taken derivations unrun and fell through to the effect phase, running effects against a knowingly half-settled graph — the glitch the ordering exists to prevent. It also allowed up to MAX_FLUSH_PASSES² observer runs and one warning per outer pass, since `derivationPasses` resets each time round. It now aborts the flush, as the outer guard already does. - A test's name said the effect does not run at all while its assertion required exactly one run. Renamed to what it asserts, so nobody "fixes" the scheduler to match the title. Both behavioural fixes have a regression test that fails without them. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
Resolves the changelog conflict. #222's entries and #210's land in one Unreleased block, ordered Added / Changed / Fixed / Removed as the released sections are, with the table of contents following suit. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
Resolves the changelog conflict: #223's rateLimit() bullet joins the Unreleased Added list beside #210's and #222's. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
There was an error while loading. Please reload this page.
Co-authored-by: Claude Opus 5 <[email protected]> Closes #210
## Summary - Release PR for **1.17.0**, cutting everything on `dev` since `1.16.1` into `main`. - Feature release for `server` and `security`, plus the packaging fixes that make the published tarball resolve correctly for the first time. - **One user-visible break:** `dist/full.umd.js` no longer exists. Details under [Upgrading](#upgrading). > **You asked for 1.7.0.** `v1.7.0` is already tagged and the package is on `1.16.1`, so that number would be a downgrade. Cut as **1.17.0** per your confirmation. ## Motivation `dev` is 14 commits ahead of `main` and carries four new features, one lifecycle change, one reactive fix and the packaging work. Closes the release cycle for #210, #211, #212, #213, #215, #216, #218, #219, #220, #222, #223 and #229 — all twelve issues are closed and their PRs merged. ## What this PR's diff actually contains > **Correction, kept in place.** This section originally read "No source changes. The diff is 8 files plus the new release-notes page." That was true of the release commit alone and has not been true of the PR for some time. Left standing rather than quietly deleted, because the same staleness is what the note below guards against. The PR targets `main`, so GitHub shows the **whole `1.16.1 → 1.17.0` delta: 73 files, +9985/−555** — all of `serveStatic`, `rateLimit`, both sanitizer backends, the reactive scheduler and the packaging work. Review it as a feature release, not as a version bump. Measured against `dev`, the delta is **35 files, +1766/−258**: the version bump, the release notes, and eleven source fixes pushed onto this branch during review that are **not on `dev`**: | Commit | Change | | --- | --- | | `1c6b5fb` | SSR validates tag and attribute names at every serialization site — **this is the CodeQL `js/xss-through-dom` fix** | | `f0c64b5` | `rateLimit` keys on the rightmost (proxy-added) `X-Forwarded-For` hop | | `407a6c5` | `trustProxy` reads one proxy-controlled header instead of whichever turns up | | `9e02000` | `postbuild-cts`/`postbuild-types` keep rewriting specifiers that wrap across lines | | `e668d14` | One text-extraction rule across both sanitizer backends; the HTML-sink path escapes | | `29dbcbf` | `serveStatic` can revalidate a precompressed asset, and ranks encodings by q-value | | `1192a87` | Limiters sharing a `SessionStore` serialize against each other | | `786bd4d` | A trailing slash no longer closes an element HTML will not let close itself; entities decode on the DOM text-only path | | `4d10b5e` | Character references decode by the tokenizer's rules (case, `;`, legacy prefixes, attribute rule) from an HTML 4.01 + ASCII + legacy table; **raises five bundle budgets by ~1.8 kB** | | `4c269a4` | A precompressed sidecar's `ETag`/`Last-Modified` come from the sidecar; its `304` no longer offers ranges | | `0fc9849` | The workflow branch guard fails on unreadable triggers even when it has no branch list | ## Version and metadata - `package.json`: `1.16.1` → `1.17.0`. - `AGENT.md`, `llms.txt`, `.cursorrules`, `.clinerules`, `.github/copilot-instructions.md`: version baseline and a new highlights section each. `bun run check:ai-guidance` enforces these and passes. **Changelog** - `## [Unreleased]` → `## [1.17.0] - 2026-09-21`, section headings and the table of contents renamed to match. - **Three Changed entries added that were missing**, plus a Fixed entry per review-round fix. See the note below. **Docs** - New `docs/release-notes/1.17.md` — the release process requires a page per minor. - Row added to `docs/release-notes/index.md`. ## The changelog gap I had to close first The `Unreleased` block on `dev` covered only #205, #210, #222, #223 and #229. The packaging work had merged with **no changelog entry at all**: | Merged | Missing from the changelog | | --- | --- | | #219 (PR #234) | `dist/full.umd.js` → `.cjs`, CDN fields → `full.iife.js`, `require()` fixed, `types` first, `./package.json` export, `.d.cts` tree | | #218 (PR #236) | Extensionless declaration imports fixed (315 × `TS2834` → 0) | | #220 (PR #235) | JS source maps dropped from the tarball | The first row is this release's only breaking change. A release that ships a 404 for pinned CDN URLs without a changelog line is how users find out the hard way, so those three entries are now written up under `### Changed (1.17.0)`. The remaining merged work (#211, #212, #213, #215, #216, #243, #244) is CI and repo tooling with no effect on consumers, so it stays out of the changelog by design. ## Validation Against `0fc9849`: - [x] `bun run lint` — clean - [x] `bun run build` - [x] `bun test` — **3518 pass, 1 skip, 0 fail** - [x] `npx tsc --noEmit` — clean | Gate | Result | | --- | --- | | `check:ai-guidance` | in sync at 1.17.0 | | `check` (full-bundle, stability, doc-exports, theme-links, workflow-branches) | pass | | `format:check` | pass | | `check:package` | 1.38 MB packed / 5.39 MB unpacked / 1240 files | | `check:size` | 25/25 within budget, largest `./full` at 133.4 kB gzipped | | `check:publish` | publint + attw exit 0 | | `check:coverage` | 89.27% lines / 87.88% functions | | `build:docs` | VitePress builds with the new release-notes page | The entity decoder was also checked outside the test suite: differential-tested against Chromium's `DOMParser` on 60,046 generated text and attribute inputs, with 0 differences outside names the table deliberately leaves out (the previous decoder differed on 9,240). **One unexplained result, recorded rather than rounded off.** An earlier full-suite run reported `3497 pass / 1 fail`. It has not recurred in 23 further full-suite runs or 25 targeted runs of the changed files, every test file passes in isolation, and the output gave only the counts — so I do not have the name of the test that failed. Calling it a flake would be a guess; it is listed here because an unreproduced failure is worth a reviewer knowing about. ## Checklist - [x] I followed the contributing guidelines. - [x] I added or updated tests where behavior changed. - [x] I updated documentation when public behavior or developer workflow changed. - [x] I confirmed the change does not introduce security-sensitive regressions. ## Upgrading Package specifiers (`@bquery/bquery`, `@bquery/bquery/*`) are unaffected and resolve better than before. One path changed: | Before | After | | --- | --- | | `dist/full.umd.js` | `dist/full.umd.cjs` | | `unpkg` / `jsdelivr` → UMD build | → `dist/full.iife.js` | A pinned `unpkg.com/@bquery/bquery@1/dist/full.umd.js` will **404**. `full.iife.js` sets the same `window.bQuery` global. Behaviour changes worth checking: - `app.use()` middleware now runs for requests matching no route. - `batch()` runs effects only after derivations settle, so code relying on observing an intermediate state inside a batch sees the settled state. - SSR drops attribute and tag names outside the validated ASCII patterns, so a non-ASCII name such as `<div é="x">` is no longer serialized. - **In the browser**, `stripTags()` no longer surfaces `<script>`/`<style>` source as text — `stripTags('<script>alert(1)</script>')` is now `''` rather than `'alert(1)'`, matching the server. - **In the browser**, `sanitizeHtml(html, { stripAllTags: true })` now escapes its output. It is branded `SanitizedHtml` and callers assign it to HTML sinks, so returning entity-decoded text raw handed back live markup. `stripTags()` is unaffected and still returns raw plain text. - The sanitizer decodes character references like a browser: `café` is `café` on the server and on the browser's text-only path, names are case-sensitive, and `href="?a=1©=2"` is left intact. HTML5-only names outside the table (e.g. `☆`) stay literal on the string backend. Every entry that sanitizes (`core`, `component`, `security`, `view`, `storybook`, and the umbrella entries) grows by about **1.8 kB gzipped**. ## Notes for reviewers **Why 1.17.0 and not 2.0.0.** There is a defensible major case, and it got slightly stronger during review: the UMD path removal, the `app.use()` lifecycle change, the `batch()` ordering change and now two browser-visible sanitizer changes are all observable. I still read them as minor because no exported API was removed or changed shape, the UMD break is a deep file path rather than a package specifier, the sanitizer changes remove a leak rather than alter a contract, and #242 deliberately kept `'sync'` the default scheduler for all of 1.x precisely to avoid forcing a major. If you weigh the CDN path break higher, say so and I will recut as 2.0.0 — it is a re-run of the same mechanical edits. **The entity table is a size trade-off you may want to overrule.** It is every HTML 4.01 name plus every ASCII-valued and legacy-prefixed name: ~1.3 kB of the ~1.8 kB. The full HTML5 table would be ~7.4 kB; dropping the Greek and math symbols would save ~0.6 kB at the cost of `α`, `≤`, `∞` and friends diverging between backends. Either direction is a small follow-up. **Branch choice, and why the back-merge is mandatory.** This is `release/1.17.0` off `dev` rather than `dev → main` directly, so `dev` stays at `1.16.1` if you defer the release. That was the only cost when this PR opened. It is not any more: the eleven fixes listed above live **only on this branch**. Merging to `main` without back-merging `main` into `dev` would leave `dev` without the CodeQL fix, both rate-limiter hardening commits, the shared-store lock and the sanitizer fixes — and the next release cut from `dev` would reintroduce all of them as regressions. So after merge, either back-merge `main` into `dev`, or say the word and I will redo this as a straight `dev → main` PR, which removes the follow-up entirely. **`main` has one commit `dev` does not** (`fe51cb5`, the squash of #208). The merge is conflict-free — verified with `git merge-tree` — but that squash is why `dev` and `main` diverge rather than fast-forward. Note also that `dev`'s squash-merges carry different SHAs than the same content on this branch, so compare the two by content (`git diff origin/dev origin/release/1.17.0`), not by commit list. **Not yet done, deliberately:** no git tag and no `npm publish`. Tagging `v1.17.0` belongs on the merge commit, and publishing is maintainer-only per the release process. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz --- _Generated by [Claude Code](https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz)_ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added static file serving with caching, range requests, compressed assets, directory indexes, and traversal protection. * Added fixed-window rate limiting with standard headers and `429` responses; a key function is required unless trusted proxy headers are used. * Added DOM-free HTML sanitization with configurable backends. * Added opt-in batched reactive scheduling, scheduler configuration, and synchronous flushing. * Global middleware now runs for unmatched routes. * **Changed** * Renamed the CommonJS bundle from `full.umd.js` to `full.umd.cjs`; browser scripts use `full.iife.js`. * Improved package type declarations and source-map handling. * Reactive updates now settle derived values before effects run. * **Bug Fixes** * Hardened server-rendered HTML by rejecting invalid tag and attribute names. * Improved compressed-asset revalidation and sanitizer handling of HTML entities. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 <[email protected]>
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.