Skip to content

Upgrade Preact to 11.2 with unmount lifecycle fixes - #249

Merged
valthon merged 3 commits into
mainfrom
codex/preact-11
Nov 9, 2026
Merged

valthon merged 3 commits into
mainfrom
codex/preact-11

Conversation

@valthon

@valthon valthon commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

Summary

Upgrade the shared runtime from Preact 10.29.8 to 11.2.0. The automatic bump in #246 cannot build because our compatibility bridge exports SuspenseList, which Preact 11 removed. Removing those exports alone also exposes deferred unmount cleanup: a pending guard can redirect after its Router is removed, while viewport observers and reload-state listeners remain active.

  • Remove SuspenseList from both compatibility entry points and expose Preact 11's use and useEffectEvent APIs. Breaking change: callers importing SuspenseList must remove that import; Suspense remains supported.
  • Detach Router subscriptions, the global base, viewport observers, and reload-state listeners from layout cleanup. Router setup and guard execution stay in passive effects, so a first guard still runs after descendants' effects and redirect aliases do not run guards twice.
  • Reject guard results and lazy route results after unmount through a synchronously cleared mounted ref.
  • Fix a lazy-load bug that predates Preact 11: an abandoned load left its rejected promise cached, which stranded the next visit on the skeleton. It now retries.
  • Link passes a caller's ref to its anchor, because Preact 11 delivers refs in props.
  • Viewport prefetch ignores already-queued notifications and observes against the current route table.
  • Update all four Bun lockfiles together. Preact is the only resolved registry package whose version changes; the existing string renderer 6.8.2 satisfies its peer requirement.

Caller-visible Preact 11 behaviour, recorded in the changelog: function components receive ref in props, compat's version is now 19.0.0, and use(promise) cannot be prerendered because the sidecar renders synchronously.

Supersedes the proposed Preact bump in #246.

Documentation & examples sync

  • Changelog fragment records the upgrade, caller-visible changes, and public API removal.
  • docs/dependency-audit.md explains the migration and regression checks.
  • docs/spa.md records the use(promise) prerender limit and the routes identity advice for viewport prefetch.
  • Site, TSX example, and styling example lockfiles resolve the upgraded runtime.
  • N/A: CLI flags, environment variables, runtime entry points, and example application APIs are unchanged.
  • N/A: Zig code-quality checklist; this PR changes no Zig source.

Verification

Three commits on main. Each one, in a clean detached checkout, passed the branding and confidentiality gates, a frozen install, the Bun runtime suite, and the runtime typecheck. The tip runs 826 Bun tests with 0 failures.

The new router regression tests were verified to fail without their fixes.

An earlier revision of this migration passed all 41 local Linux workflow verification steps and its nine GitHub CI checks. The final revision differs from it only in router code, tests, and documentation. GitHub CI on this head is the check for the full matrix.

UNVERIFIED: macOS nightly lane; the local host is Linux. The Zig suites, shell e2e, and browser lanes were not re-run locally on this head.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HTAHrjUxHvSdfgaDLibY1X

Copilot AI balanced review requested due to automatic review settings November 8, 2026 01:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Layout cleanup leaves queued intersection callbacks able to prefetch after a Link unmounts; an active-state guard and a test delivering a queued notification after removal are needed.

1 open issue
What changed in this PR

Upgrades the shared runtime to Preact 11.2.0, replacing the dependency-only bump proposed in #246 with compatibility updates and unmount lifecycle protections.

Changes:

  • Removes SuspenseList exports and exposes use and useEffectEvent through both compatibility entry points.
  • Moves mount-owned cleanup to layout effects and prevents pending guards from acting after Router unmount.
  • Synchronizes all four lockfiles, updates export and unmount assertions, and documents the migration.
File Description
site/​bun.lock Resolves the site's runtime dependency to Preact 11.2.0.
runtime/​test/​compat.test.ts Checks the new compatibility exports.
runtime/​test/​browser-entry-compat.test.ts Checks the new exports in the built browser bundle.
runtime/​src/​router.ts Updates Router and Link cleanup and guards against post-unmount results.
runtime/​src/​router.test.ts Checks base removal immediately after unmount.
runtime/​src/​dev-reload.ts Removes reload-state listeners synchronously on unmount.
runtime/​src/​compat/​index.ts Removes SuspenseList and exports the new APIs.
runtime/​src/​browser-entry.ts Aligns browser exports with the updated compatibility bridge.
runtime/​package.json Raises the Preact dependency to ^11.2.0.
runtime/​bun.lock Locks the runtime to Preact 11.2.0.
examples/​tsx-site/​bun.lock Synchronizes the TSX example's Preact dependency.
examples/​styling-site/​bun.lock Synchronizes the styling example's Preact dependency.
docs/​dependency-audit.md Records migration rationale and validation results.
changelog.d/​preact-11.md Documents lifecycle changes and the breaking export removal.

Review effort: Balanced

Comment thread runtime/src/router.ts Resolved

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues are blocking approval of the Preact 11.2.0 migration.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Comment thread runtime/src/router.ts Resolved
Comment thread runtime/src/router.ts Resolved
Comment thread runtime/src/compat/index.ts Resolved
Comment thread runtime/src/router.ts Resolved
Comment thread changelog.d/preact-11.md Resolved
Comment thread docs/dependency-audit.md Resolved
Comment thread runtime/src/router.ts Resolved
Comment thread runtime/src/router.ts Resolved
valthon and others added 3 commits November 8, 2026 20:07
Preact 11 no longer ships SuspenseList, and both the compatibility bridge
and the shared browser entry re-export it, so the Dependabot bump to 11.0.2
in #246 cannot build. Drop both exports ahead of the upgrade.

This is a breaking change for callers that import SuspenseList from
@z/runtime/compat or the shared bundle. Suspense itself stays available.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HTAHrjUxHvSdfgaDLibY1X
Move every lockfile from Preact 10.29.8 to 11.2.0. The string renderer
stays at 6.8.2, which meets 11.2's peer requirement. The compatibility
bridge gains use and useEffectEvent.

Preact 11 defers passive-effect cleanup on unmount until after paint, so
anything that relied on it being synchronous gets a window in which a
removed Router or view still acts. Close each one:

- The Router's location listener and global base detach from a layout
  cleanup. Only teardown moves: setup stays passive, because a setState
  from a layout effect flushes the Router's passive effects before any
  descendant's and would run the first guard ahead of a child's useEffect.
- Pending guards and lazy route loads check a mounted ref that the layout
  cleanup clears.
- The viewport observer and the reload-state listener use layout cleanup.

That lazy-load path also had a bug that predates Preact 11: an abandoned
load left its rejected promise cached, so the next visit inherited the
failure and latched the route on its skeleton. It now drops the promise
and the next visit retries.

Preact 11 also passes ref to function components in props, so Link merges
a caller's ref with the anchor ref its observer needs. A callback ref may
return a cleanup, in which case Preact skips the null call, so the merged
ref passes that cleanup on.

use(promise) cannot be prerendered because the sidecar renders with the
synchronous renderToString; docs/spa.md records the limit. The changelog
also notes the ref change and compat's version moving to 19.0.0, which
alters what npm React libraries detect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HTAHrjUxHvSdfgaDLibY1X
disconnect() does not retract a notification that is already queued, so a
Link could prefetch after it unmounted or after its one observation had
already fired. An observation-local flag now rejects those.

The observer also closed over ctx.prefetch while its deps named only the
mode and href. ctx.prefetch is re-created when the Router's routes change,
so a new table with the same href kept prefetching against the old one.
Keying on it re-observes, at the cost of observer churn when a parent
passes an inline routes literal; docs/spa.md asks for a stable identity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HTAHrjUxHvSdfgaDLibY1X
@valthon
valthon merged commit 9472fe5 into main Nov 9, 2026
9 checks passed
@valthon
valthon deleted the codex/preact-11 branch November 9, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants