GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — feat(reactive): add an opt-in glitch-free batched scheduler by JosunLP · Pull Request #242 · bQuery/bQuery

Conversation

@JosunLP

@JosunLP JosunLP commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds configureReactive({ scheduler: 'sync' | 'batched' }), getReactiveConfig() and flushSync(). 'sync' stays the default for all of 1.x.
  • Also fixes a flush-ordering bug that microtask batching alone did not solve — and that one applies under the default scheduler too. That's the part to review carefully.

Motivation

Closes #210. Reproduced exactly as reported:

unbatched:    [1, 2, 4, 5, 7]     ← 2 and 5 are glitches
batch() each: [1, 4, 7]           ← correct

Changes included

  • New src/reactive/config.ts — configureReactive, getReactiveConfig, ReactiveScheduler.
  • src/reactive/internals.ts — microtask auto-batch under 'batched'; derivations drained to a fixed point before effects; flushSyncInternal.
  • src/reactive/computed.ts — marks its two observers as derivations.
  • src/reactive/batch.ts — public flushSync().
  • Barrels + src/full.ts; docs/guide/reactive.md gains a "Scheduler" section; CHANGELOG under Added and Fixed.
  • New tests/reactive-scheduler.test.ts — 21 tests, structured as the issue's four acceptance criteria.

Acceptance criteria

From the issue Status
Diamond graph produces no intermediate values ✅ [1, 4, 7]
N writes in one tick produce one effect run ✅ 100 writes → 1 run
Effects that write signals still settle (MAX_FLUSH_PASSES) ✅ incl. a cyclic pair that terminates
Ordering is parent-before-child for nested scopes ✅

Validation

  • bun run lint — bunx eslint . clean
  • bun run build
  • bun test — 3088 pass, 1 fail (the pre-existing [Bug]: IPv6 server test fails instead of skipping in IPv6-less environments #213 IPv6 test, fixed in fix(tests): skip the IPv6 listen test when the runtime has no IPv6 loopback #232)
  • Additional focused checks: bun run check, format:check, lint:types pass

The performance claim in the issue, measured here. 1000 writes to one signal with 1000 subscribed effects, Bun 1.3.11:

Scheduler Time Effect invocations
'sync' 1314 ms 1,000,000
'batched' 44 ms 1,000

Checklist

  • I followed the contributing guidelines.
  • I added or updated tests where behavior changed.
  • I updated documentation when public behavior or developer workflow changed.
  • I confirmed the change does not introduce security-sensitive regressions.

Notes for reviewers

The part that goes beyond the flag, and why

Microtask 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 [4, 4] — a fresh double beside a stale quadruple. The pending-observer queue mixed computed revalidations with effects in plain insertion order, and because the effect subscribed to double before quadruple did, it was queued first and ran between the two recomputations.

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. markDirty is what downstream computeds subscribe with; revalidate is what it schedules in turn. Marking only revalidate left markDirty queued alongside effects and the glitch persisted.

This ordering change applies to explicit batch() under the default 'sync' scheduler too, so it is a behaviour change outside the opt-in flag. I judged it a strict correctness improvement rather than something to gate — an effect never being able to read a half-updated graph is what batch() already promises. The existing suite passes unchanged. If you'd rather keep 1.x byte-for-byte on the default path, I can gate it behind the flag; it would mean batch() keeps the glitch.

Why reuse the batch machinery rather than write a new scheduler

The issue proposes a two-phase mark-dirty/flush design. computed already does lazy pull with dirty propagation, and batch() already produced the correct [1, 7] — the missing pieces were when the flush happens and in what order it runs. Reusing beginBatch/endBatch for the microtask flush means the 'batched' path goes through the same code that has been exercised by every batch() call to date, rather than a parallel implementation.

Smaller notes

  • flushSync() inside a batch() deliberately leaves the work with the enclosing batch rather than breaking the batch open — there's a test for that.
  • Switching schedulers mid-run is tested both ways; work queued before a switch back to 'sync' still drains on its microtask.
  • One flaky test to be aware of, not caused by this PR. useWebSocket — new extensions > measures latency via heartbeat RTT failed on one full-suite run. I checked it: 0/4 failures on this branch and 0/3 on dev across repeated full runs, and 3/3 passes in isolation. It's a timing-sensitive RTT assertion that flakes on its own; out of scope here, but it may want hardening.

🤖 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 reactions
@JosunLP @claude
feat(reactive): add an opt-in glitch-free batched scheduler
cb83227
In 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
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: e8560b85-cdcc-4b11-b161-993ffcec3100

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share
  • X
  • Mastodon
  • Reddit
  • LinkedIn

Comment @coderabbitai help to get the list of available commands.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@github-actions github-actions Bot added reactive Changes to the reactive module docs Changes to the documentation tests Chenges to the tests labels Sep 19, 2026
@JosunLP @claude
fix(tests): avoid Array.prototype.at in the scheduler test
c4055ef
`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
@JosunLP JosunLP mentioned this pull request Sep 19, 2026
feat(security): sanitize HTML without a DOM #238
Merged
8 tasks done
@JosunLP
Merge branch 'dev' into fix/210-reactive-scheduler
1ab3030
@JosunLP
JosunLP marked this pull request as ready for review September 20, 2026 12:52
JosunLP
JosunLP commented Sep 20, 2026
View reviewed changes

@JosunLP JosunLP left a comment

Copy link
Copy Markdown
Collaborator Author

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.

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).
  • Fuzzed 60 random signal/computed DAGs plus 60 randomized diamond runs: the value an effect observes under '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).
  • Marking both 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.
  • The new ordering does not regress the flush-pass budget. A computed chain of depth 50 now settles where it previously warned and dropped the update (dev: [51], this branch: [51, 52]); depth 60 hits the cap on both.
  • Batch/flush depth arithmetic is balanced on every path I could construct (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.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread src/reactive/config.ts
* configureReactive({ scheduler: 'batched' });
* ```
*/
export const configureReactive = (options: Partial<ReactiveConfig>): void => {

@JosunLP JosunLP Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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:

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

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread src/reactive/batch.ts
* expect(rendered).toBe('1'); // no `await` needed
* ```
*/
export const flushSync = (): void => {

@JosunLP JosunLP Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread src/reactive/internals.ts
if (++derivationPasses > MAX_FLUSH_PASSES) {
warnUnsettled();
break;
}

@JosunLP JosunLP Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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:

  1. The derivations in 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.
  2. The outer loop then keeps iterating. Because 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:

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

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread tests/reactive-scheduler.test.ts Outdated
expect(runs).toBe(4); // creation + one per tick
});

it('does not run an effect at all when the value settles back', async () => {

@JosunLP JosunLP Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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.

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

Uh oh!

There was an error while loading. Please reload this page.

All reactions
claude and others added 7 commits September 20, 2026 19:03
@claude
fix(reactive): clean scheduler handover and a loud flushSync contract
7b2f94b
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
@claude
Merge origin/dev into fix/210-reactive-scheduler
95ce888
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
@claude
Merge remote-tracking branch 'origin/dev' into fix/210-reactive-sched…
f4de7e0
…uler
@claude
Merge origin/dev into fix/210-reactive-scheduler
299f877
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
@claude
Merge origin/dev into fix/210-reactive-scheduler
3e8202b
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
@claude
Merge remote-tracking branch 'origin/dev' into fix/210-reactive-sched…
730bdb8
…uler
@JosunLP
Merge branch 'dev' into fix/210-reactive-scheduler
ea6c1a8
@JosunLP
JosunLP merged commit 1bd228c into dev Sep 21, 2026
27 checks passed

Uh oh!

There was an error while loading. Please reload this page.

@JosunLP
JosunLP deleted the fix/210-reactive-scheduler branch September 21, 2026 08:35
This was referenced Sep 21, 2026
[Bug]: Effects observe glitched intermediate states in a diamond dependency graph #210
Closed
release: 1.17.0 #245
Merged
JosunLP added a commit that referenced this pull request Sep 21, 2026
@JosunLP @claude
feat(reactive): add an opt-in glitch-free batched scheduler (#242)
ffc27b7
Co-authored-by: Claude Opus 5 <[email protected]>
Closes #210
JosunLP added a commit that referenced this pull request Sep 24, 2026
@JosunLP @claude
release: 1.17.0 (#245)
71a7d16
## 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&eacute;` is `café` on the server and on the browser's text-only
path, names are case-sensitive, and `href="?a=1&copy=2"` is left intact.
HTML5-only names outside the table (e.g. `&star;`) 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 `&alpha;`,
`&le;`, `&infin;` 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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

docs Changes to the documentation reactive Changes to the reactive module tests Chenges to the tests

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

2 participants

@JosunLP @claude
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.