Skip to content

perf(core): lazy-load ConsentLogger - #100

Open
DaSchTour wants to merge 1 commit into
mainfrom
perf/lazy-consent-logger
Open

perf(core): lazy-load ConsentLogger#100
DaSchTour wants to merge 1 commit into
mainfrom
perf/lazy-consent-logger

Conversation

@DaSchTour

Copy link
Copy Markdown
Member

Summary

ConsentManager statically imported ConsentLogger and paid its full cost even when config.logging was never set — the common case, since most consumers don't configure server-side logging. This makes the logger implementation load lazily, only when it's actually needed.

Design

  • src/core/lazy-consent-logger.ts (new) — LazyConsentLogger is a small facade with the same public shape as the real ConsentLogger (log, logSnapshot, flush). Its constructor kicks off import('./consent-logger.js'); calls made before that resolves are queued and replayed in order once it does — no record is ever silently dropped. createConsentLogger() now returns a LazyConsentLogger.
  • src/core/consent-manager.ts — constructs a LazyConsentLogger instead of the real class when config.logging is set. applyChoices() / revokeAll() / sendConfigSnapshot() call sites are unchanged (same .log() / .logSnapshot() shape).
  • src/index.ts (package root) — re-exports LazyConsentLogger as ConsentLogger, plus createConsentLogger, instead of the real eager class. keksmeister/core (built separately via tsc, not bundled — see Build below) keeps exporting the real, eager ConsentLogger/createConsentLogger unchanged, for standalone/headless use where eager construction is expected.

Why the facade is also the public export

I initially planned to leave the root's ConsentLogger/createConsentLogger exports pointing at the real class, and only make ConsentManager's internal usage lazy. That doesn't work: Rollup refuses to chunk-split a module that's also reachable via a static import (it logs INEFFECTIVE_DYNAMIC_IMPORT and merges everything into the main chunk — verified with a minimal repro before committing to this design). Since src/index.ts re-exports ConsentLogger as a real value (so new ConsentLogger() works standalone), any static reference to the real class from the bundled entry defeats the lazy-loading goal entirely — zero byte savings.

The fix: root exports the lazy facade under the name ConsentLogger. It's fully API-compatible (new ConsentLogger(opts), .log(), .logSnapshot(), .flush() all work identically, with buffering making the timing transparent) — the only observable difference is instanceof against the real class, which isn't part of the documented API. I also had to stop routing src/index.ts's other core exports (ConsentManager, CookieStore, ScriptBlocker, ServiceRegistry) through the core/index.ts barrel, since that barrel itself still statically re-exports the real ConsentLogger (for the /core subpath) — importing anything from it pulled that static edge back into the bundled entry's graph. They're now imported directly from their own modules instead.

Build changes

  • vite.config.ts: added build.rollupOptions.output.chunkFileNames: '[name].js' so the logger chunk gets a stable name (dist/consent-logger.js) instead of a content hash — needed so the build script below can target it by a fixed path.
  • No formats-specific output config was needed for the UMD-vs-ES-splitting constraint: Vite/Rollup already handles it per format automatically — the ES output code-splits the dynamic import into dist/consent-logger.js, while the UMD output (which can't code-split) transparently inlines it, so dist/keksmeister.umd.cjs stays a single self-contained file.
  • package.json build script: added a second terser invocation that minifies dist/consent-logger.js in place (with proper sourcemap chaining), so CDN/keksmeister.min.js users get minified logger code too. Both keksmeister.js and keksmeister.min.js import the same ./consent-logger.js specifier, so they share the one (minified) chunk file.
  • dist/index.js and the other tsc-built subpath outputs (dist/core/*, dist/adapters/*, ...) are unaffected — confirmed by inspecting dist/core/index.js (still exports the real ConsentLogger unchanged) and dist/index.js (tsc's own emit of src/index.ts, unused by the . export map entry but built correctly regardless).
  • .github/workflows/ci.yml's bundle-size check (gzip(dist/keksmeister.js) <= 12 kB) needed no changes — it already measures only the main chunk, which is now smaller since the logger chunk is separate. New size: 9.69 kB gzip, well under the limit.

Size delta

File Before (gzip) After (gzip) Δ
dist/keksmeister.js (ES, main) 10,781 B 9,928 B −853 B (−7.9%)
dist/keksmeister.min.js (CDN, main) 9,964 B 9,191 B −773 B (−7.8%)
dist/keksmeister.umd.cjs (self-contained) 10,077 B 10,397 B +320 B (+3.2%)
dist/consent-logger.js (new, lazy chunk, minified) 1,251 B fetched only when logging is configured

Notes:

  • The main ES/CDN bundles shrink by ~0.8 kB gzip — smaller than the ~2 kB initially estimated, since the extracted chunk itself only minifies+gzips to ~1.25 kB (the facade adds back a small amount to the main chunk).
  • UMD grows slightly: Rollup has to wrap the inlined-but-still-"dynamically-imported" module in a lazy-init helper to preserve single-evaluation semantics, which costs a few hundred bytes. This only affects the CJS/global (require/<script> non-module) consumption path; UMD isn't package.json's primary format (module/exports.import point at the ES build).

Verification

  • bun run typecheck — clean.
  • bun run test — 118/118 passing, including:
    • a new src/core/lazy-consent-logger.test.ts covering buffering, in-order replay, createConsentLogger(), and flush().
    • a new consent-manager.test.ts case asserting a consent decision made immediately after construction (before the dynamic import resolves) is still delivered, via vi.dynamicImportSettled().
    • two pre-existing consent-manager.test.ts cases updated to await vi.dynamicImportSettled() — logging is now asynchronous by nature, and without settling before a test ends, a pending replay leaked into and inflated a later test's mock call count (fixed).
  • bun run build — clean; confirmed dist/consent-logger.js is only referenced via import() in dist/keksmeister.js/.min.js, and is absent from dist/keksmeister.umd.cjs's dependency list (fully inlined there instead).
  • Lazy-loading is architectural, not incidental: the dynamic import only ever fires from inside LazyConsentLogger's constructor, which ConsentManager only invokes when config.logging is truthy — unchanged from before, just swapped to the lazy class.

🤖 Generated with Claude Code

ConsentManager statically imported ConsentLogger and paid its ~2.3 kB
gzip cost even when `config.logging` was never set, which is the common
case. ConsentManager now dynamically imports the implementation only
when logging is configured, via a small LazyConsentLogger facade that
queues log()/logSnapshot() calls made before the import resolves and
replays them in order once it does — no record is ever dropped.

The facade is also what's exported as ConsentLogger/createConsentLogger
from the package root, since Rollup can't chunk-split a module that's
also statically re-exported (confirmed via INEFFECTIVE_DYNAMIC_IMPORT).
keksmeister/core (built via tsc, not bundled) keeps exporting the real,
eager class unchanged for standalone/headless use.

Build: vite.config.ts gives the logger chunk a stable, non-hashed name
(chunkFileNames) so the build script can terser-minify it too. Rollup
automatically inlines the dynamic import for the UMD output (which
can't code-split) while still splitting it for the ES output, so no
extra rollupOptions.output config was needed for that split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 1.55kB (2.13%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
keksmeister-Keksmeister-esm 39.59kB 698 bytes (1.79%) ⬆️
keksmeister-Keksmeister-umd 34.79kB 851 bytes (2.51%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: keksmeister-Keksmeister-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
keksmeister.js -3.2kB 35.7kB -8.22%
consent-logger.js (New) 3.9kB 3.9kB 100.0% 🚀

Files in keksmeister.js:

  • ./src/index.ts → Total Size: 310 bytes

  • ./src/core/lazy-consent-logger.ts → Total Size: 894 bytes

  • ./src/core/consent-manager.ts → Total Size: 7.14kB

view changes for bundle: keksmeister-Keksmeister-umd

Assets Changed:

Asset Name Size Change Total Size Change (%)
keksmeister.umd.cjs 851 bytes 34.79kB 2.51%

Files in keksmeister.umd.cjs:

  • ./src/core/lazy-consent-logger.ts → Total Size: 979 bytes

  • ./src/core/consent-manager.ts → Total Size: 7.35kB

  • ./src/index.ts → Total Size: 314 bytes

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.94737% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/lazy-consent-logger.ts 77.77% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

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.

1 participant