Skip to content

feat: migrate qwik-sonner to Qwik v2 (beta)#15

Draft
diecodev wants to merge 36 commits into
mainfrom
v2
Draft

feat: migrate qwik-sonner to Qwik v2 (beta)#15
diecodev wants to merge 36 commits into
mainfrom
v2

Conversation

@diecodev

@diecodev diecodev commented Jun 18, 2026

Copy link
Copy Markdown
Owner

Warning

🚧 WIP — do not merge yet. This will stay a draft until Qwik v2 reaches a stable release. Until then it ships only as a prerelease (qwik-sonner@beta).

Summary

Migrates qwik-sonner from Qwik 1.x to Qwik 2.0 beta (@qwik.dev/core / @qwik.dev/router) and brings the toast core to sonner 2.0.7 parity. Published as v2.0.0-beta.1.

Install the prerelease to test it today:

pnpm add qwik-sonner@beta

Full changelog: https://github.com/diecodev/qwik-sonner/releases/tag/v2.0.0-beta.1

Closes

Highlights

  • Qwik v2 migration — all @builder.io/* packages replaced with @qwik.dev/core / @qwik.dev/router; qwikCity()qwikRouter(), service workers removed (v2 uses modulepreload).
  • Toast core — ported to Qwik v2 with sonner 2.0.7 parity; removed dompurify (title/description render as text/JSX).
  • topLayer (Popover API) support — opt-in topLayer prop renders toasts above native modal dialogs (closes the request in Use the popover api #11).
  • Signals on propsToaster accepts Signals for position, theme, richColors & dir.
  • Build — ESM-only lib build (styled + headless, shared chunk), Vite pinned to v7 (Rolldown/v8 breaks the prod optimizer), pnpm catalogs.
  • Tooling — migrated to oxlint/oxfmt; shared CI setup action + full CI gate (lint, typecheck, build, Playwright chromium+webkit).
  • Docs/site — demo + docs site migrated to Qwik Router.

Status / open items

  • Wait for Qwik v2 stable, then drop beta and cut a stable v2.0.0.
  • Validate against the stable Qwik v2 APIs (beta APIs can still shift).

Verification

pnpm build.types + pnpm build.lib green at root; pnpm build.types green in website/ and test/; Playwright e2e (chromium + webkit) green in CI.

Diego Díaz and others added 30 commits June 16, 2026 17:45
Migrate the library, website and test workspaces from Qwik 1.x to
Qwik 2.0.0-beta.37.

- Rename packages: @builder.io/qwik -> @qwik.dev/core,
  @builder.io/qwik-city -> @qwik.dev/router
- API renames: qwikCity() -> qwikRouter(), createQwikCity() ->
  createQwikRouter(), <QwikCityProvider> -> <QwikRouterProvider>,
  @qwik-city-plan -> @qwik-router-config
- Drop the qwikRouterConfig option from createQwikRouter (resolved
  internally in v2)
- Remove src/routes/service-worker.ts (v2 uses modulepreload; keep
  <ServiceWorkerRegister /> to unregister stale SWs)
- tsconfig: jsxImportSource @qwik.dev/core + moduleResolution bundler
- Bump Vite to ^7.3.5, vite-tsconfig-paths ^6, eslint-plugin-qwik v2
- Fix v2 type strictness: isSignal() arg, timeout type, task cleanup
- pnpm-workspace: allow esbuild/sharp build scripts
Add AGENTS.md as the single source of truth describing the project,
the v1->v2 rename table, v2 gotchas, monorepo layout, public API and
commands. Add thin pointers for Claude, Gemini, Codex, Cursor and
Copilot.
Upgrade Vite 7 → 8 across root, website, and test (within the Qwik v2 peer
range vite >=6 <9; Vite 8 uses Rolldown).

Build process overhaul (vite.config.ts):
- Fix externalization. Root cause: package.json declared no dependencies /
  peerDependencies, so the config's dep-based externalization was a no-op and
  DOMPurify got inlined (~80 kB). Now @qwik.dev/core is a peerDependency and
  dompurify a dependency, so neither is bundled.
- Single-pass multi-entry lib build (was two vite builds toggled by an ENTRY
  env var). Shared headless core is hoisted into one chunk instead of being
  duplicated across the index and headless entries.
- ESM-only output (dropped CJS). exports now lists `types` first; added
  `module` and `sideEffects: false`. prepublishOnly runs build.types +
  build.lib.

Result: index.qwik.mjs 99.6 kB → 17 kB. Dropped @types/dompurify (deprecated;
DOMPurify 3.x ships its own types).
Drop the library's only runtime dependency. title/description are now
rendered as text/JSX nodes (the sonner model) instead of being parsed as
sanitized HTML via dangerouslySetInnerHTML + DOMPurify. The package is now
dependency-free and fully tree-shakeable.

BREAKING CHANGE: string title/description values that previously rendered
as HTML markup now render as plain text. Pass JSX for rich content.
Refine the sonner port on Qwik v2: export useSonner and additional public
types (ToastClassnames, ToastToDismiss, Action, Promise result types with JSX
support), rework headless state/toast/toast-wrapper logic, icons, and styled CSS.
Also wire tailwind + qwikRouter into the lib vite config, set rootDir in
tsconfig.lib.json, drop the unused dev entry, and remove dead imports in root.tsx.
Broaden the Playwright e2e coverage and the test app routes/entries, run the
suite against chromium + webkit, and refresh the website demo layout. Drop the
unused test dev entry and the website postcss config (tailwind v4 needs none).
- Adopt a pnpm catalog for shared dependency versions across all workspaces.
- Replace ESLint + Prettier with oxlint + oxfmt. eslint-plugin-qwik is loaded
  via oxlint's jsPlugins (.oxlintrc.json); the two type-aware Qwik rules
  (valid-lexical-scope, use-async-top) can't run under the JS-plugin runtime and
  are omitted. Remove all eslint/prettier configs and their tsconfig references,
  and point the VS Code extension recommendation at oxc.
- Pin vite to ^7 in the catalog. Vite 8 switches to Rolldown, under which the
  Qwik optimizer fails to register internal runtime QRL symbols (_run/_task) in
  production builds, throwing Q14 (qrlMissingChunk) during prod SSR. Vite 7
  (Rollup) builds clean; verified pnpm preview renders with 0 errors.
- Track remaining follow-ups in todos.md.
Catalog/peer-dep alignment for @qwik.dev/core, tsconfig moduleResolution "bundler" + jsxImportSource, single-pass ESM lib build, and Playwright/Vite config for the v2 router. Vite pinned to ^7 (Rolldown breaks the prod optimizer).
AGENTS.md is now the single source of truth (CLAUDE.md/GEMINI.md point here); README/CONTRIBUTING updated for Qwik v2 + sonner 2.0.7 parity; todos.md tracks the migration. Removes stale .github/copilot-instructions.md.
Port the docs/demo app to @qwik.dev/router (createQwikRouter, QwikRouterProvider), refresh components and styles, and align the Toaster usage with the v2 DOM contract.
Test app migrated to @qwik.dev/router; basic.spec.ts covers promise/extended-promise/unwrap, focus return, toasterId routing, testId, and multi-direction swipe dismissal (36/36 chromium).
Migrate the library to @qwik.dev/core: multi-direction swipe with drag damping, per-toast heights/offsets filtered by position, measureHeight races fixed, sync$ pointer-capture, useSonner(), getToasts/getHistory, and sonner 2.0.7's verbatim styles.css + data-sonner-* DOM contract.

BREAKING CHANGE: ESM-only, @qwik.dev/core peer dep, data-sonner-* attribute/class names.
On swipe-dismiss the toast snapped back to its initial position and faded in place instead of sliding off. Turning on swipeOut re-renders the <li>, and Qwik rewrites the style attribute from the style object — wiping the imperatively-set --swipe-amount-x/y. The swipe-out keyframes read those vars with no fallback, so transform computed to none. Commit the release offsets into the rendered style object so they survive the re-render.
Make the repo-wide scan explicit in the lint/fmt scripts (oxlint . / oxfmt .)
so coverage of website/ and test/ can't silently regress, and update AGENTS.md
which still documented the stale "oxlint src" scope.
The enter animation flips `data-mounted` false→true and lets CSS transition
it, but a freshly-inserted element can't animate on its first paint. The flip
was scheduled from a setup-time `requestAnimationFrame` (no `track`), so it
raced Qwik's element commit — the very first toast (which also creates its
`<ol>` in the same render) intermittently never animated.

Anchor the flip to the element ref and defer it past the next paint with a
double rAF (`afterNextPaint`), so the collapsed state is always painted before
the flip. Verified deterministic on chromium + webkit.

The dismiss-callback swipe test relied on the first toast snapping instantly
into place, so it now waits for the enter animation to settle before
measuring and swiping.
New opt-in `topLayer` prop promotes the toaster to the browser's top layer via
the native Popover API (`popover="manual"` + `showPopover()`), so toasts render
above native modals (`<dialog>.showModal()`) and fullscreen elements, which
otherwise paint over any `z-index` (issue #11). Off by default for sonner
parity; no-ops where the Popover API is unavailable.

The styled entry ships the UA `[popover]` reset so the container spans the
viewport as a transparent pass-through layer; the inner lists keep their fixed
positioning and pointer events.

Known limitation: on WebKit the promoted `<section>` is `display:none` until
shown, so its `onQVisible$` (IntersectionObserver) subscription never fires and
no toasts render while `topLayer` is on. Under investigation.
Adds the topLayer harness to the dev route (a native `<dialog>` modal with
open/close + fire-toast-from-modal buttons and a `?topLayer=1` toggle) and an
e2e spec asserting: default toasters stay behind a modal (issue #11), topLayer
promotes them above it, the popover opens/closes with the toast count, and the
full-viewport layer passes pointer events through.

Popover-API assertions pass on Chromium; on WebKit they fail due to the
subscription-timing limitation noted in the feat commit.
A `<button>` may only contain phrasing content, but the copy buttons wrapped
their icon in a `<div>` (flow content). Qwik v2 rejects this during SSR
(Code(Q12) SsrError: "HTML rules do not allow '<div>' at this location"),
crashing `pnpm dev`. Swap the wrappers for `<span>` (valid in a button) in
both Installation and CodeBlock, and rename the `.copy div` rule to
`.copy span` so the flex centering is preserved.
These props now accept `Signal<T> | T` (joining `invert`), so they can change
reactively at runtime on the SSR-resumed Toaster singleton instead of being read
only at mount. Values are unwrapped with `isSignal()`:

- position/theme: the reactive computed/task reads the unwrapped value, so a
  signal change re-runs on the resumed singleton.
- dir: new `resolveDir()` helper unwraps the signal and re-reads the document
  direction each render, so `"auto"` keeps tracking the live `<html dir>`.
- richColors: the per-toast computed unwraps the (now possibly-Signal)
  `defaultRichColors`.

Adds an exported `Direction` type.
Qwik v2 SSR-entry migration for the demo/docs site:

- entry.ssr.tsx now uses `createRenderer` from `@qwik.dev/router` instead of the
  manual `renderToStream` + `@qwik-client-manifest` wiring.
- Remove the unused `entry.deno.ts` and `entry.dev.tsx` entry points.
- Drop the unused `autoprefixer` dependency (and its lockfile entries).
- New `/docs` route: a full guide covering install, toast types, actions,
  promises, the Toaster props reference (including the signal-capable props),
  styling, top layer, and useSonner — with live interactive demos.
- Link to the docs from the Hero.
- Landing page: move the `<Toaster>` to the end of the tree and drop the stale
  resumability-positioning warning.
Stop tracking the local backlog file and add it to .gitignore; it stays on
disk but is no longer part of the repo.
Replace test.yml with ci.yml: a reusable workflow (workflow_call) that runs
the green bar (lint, build.types+build.lib, website/test typecheck) and
Playwright across a chromium/webkit matrix with browser caching. Extract
pnpm+Node+install into a composite action so jobs don't duplicate setup.
Release now reuses ci.yml as a quality gate before publishing. Switch the
publish step to npm OIDC Trusted Publishing: pnpm pack rewrites catalog:/
workspace: specifiers into the tarball, then npm publish *.tgz --provenance
authenticates via id-token (no NPM_TOKEN). pnpm publish is avoided because it
does not yet support npm's OIDC trusted publishing.
diecodev added 6 commits June 18, 2026 00:25
Derive the npm dist-tag from the package version: prerelease versions
(2.0.0-beta.N) publish under 'beta' so the 'latest' tag keeps pointing at the
stable line; plain versions publish under 'latest'.
The lib build only bundles src/lib/** and never needed the router; qwikRouter()
required a src/routes dir that git can't track when empty, so the lib build
failed on a clean CI checkout. Remove the router dep, the qwikRouter plugin,
and the unused root SSR harness (root.tsx, entry.ssr.tsx). Root dev/start now
delegate to the test app (pnpm --filter test ...), and build is the lib build.
The Toaster wired its client-side toast subscription via
useOnDocument("DOMContentLoaded", ...). That one-shot event has already
fired by the time the qwikloader wires the listener in WebKit, so the
handler never ran and the singleton never subscribed: every toast was
published to zero subscribers and nothing rendered (all WebKit e2e tests
that open a toast failed).

Switch to the qinit Qwik init event, which the qwikloader dispatches on
readystatechange (and immediately on load) with a replay path for
late-registered listeners, so it fires regardless of page-load timing.
Unlike qvisible/useVisibleTask$ (IntersectionObserver-based) it also
fires when the Toaster renders below the fold.
The cache key omitted matrix.browser, so the chromium and webkit legs
shared one entry. Only the first finisher saved it (one browser), and
later runs got a cache hit that skipped the browser install — leaving
the other leg launching against a missing binary (webkit-2311/pw_run.sh
not found, all 42 webkit tests failing at launch).

Add matrix.browser to the key so each browser gets its own entry, and
bump a v2- prefix to discard the poisoned cache.
@diecodev diecodev changed the title feat!: migrate qwik-sonner to Qwik v2 (beta) feat: migrate qwik-sonner to Qwik v2 (beta) Jun 18, 2026
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.

Use the popover api

1 participant