Skip to content

fix(core): scope the Svelte PDF context per <EmbedPDF> instance - #753

Open
claeyzre wants to merge 2 commits into
embedpdf:v2from
claeyzre:fix/svelte-per-instance-context
Open

fix(core): scope the Svelte PDF context per <EmbedPDF> instance#753
claeyzre wants to merge 2 commits into
embedpdf:v2from
claeyzre:fix/svelte-per-instance-context

Conversation

@claeyzre

Copy link
Copy Markdown

Fixes #752.

What

The Svelte adapter keeps its context in one module-level $state object that every consumer reads, so two <EmbedPDF> instances on a page overwrite each other's registry, coreState and activeDocumentId, and unmounting either one resets both.

Each instance now creates its own context and publishes it with setContext, the way the React, Preact and Vue adapters already scope theirs. usePlugin goes through useRegistry() instead of importing the module object directly, also matching the others.

Three files in packages/core/src/svelte, a new test suite, and a changeset:

  • hooks/use-registry.svelte.tscreatePdfContext() mints a fresh reactive context; setPdfContext/useRegistry use setContext/getContext. The key is Symbol.for('@embedpdf/core:pdf-context'), so two accidentally bundled copies of the package still agree on it.
  • hooks/use-plugin.svelte.tsuseRegistry() instead of the module import
  • components/EmbedPDF.svelte — two lines to create and publish the context; everything below is untouched because the local binding keeps the name

Tests

packages/core had no test setup, so this adds one: vitest + jsdom + @sveltejs/vite-plugin-svelte, wired as pnpm --filter @embedpdf/core test. Six tests in tests/svelte/context.test.ts cover:

  • two instances receive distinct contexts
  • the children snippet and descendant components resolve the same instance context
  • unmounting one instance leaves the sibling's context untouched
  • consumers with no <EmbedPDF> ancestor get the inert fallback and a warning
  • the warning fires on every resolution (see below for why)
  • the fallback is a single frozen object; writes to it throw

All six fail against the current v2 implementation — verified by swapping the old src/svelte back in and re-running.

Before / after

The second commit adds examples/svelte-tailwind/src/routes/dual, which mounts two instances side by side and prints the context each one resolves.

Against v2 as it stands, both panels report the same activeDocumentId, both render nothing, and the console explains why:

Cannot register viewport for doc-…-un48eeg3i: document state not found
No scroller layout emitter found for document: doc-…-un48eeg3i

One panel's document id is being used against the other panel's registry.

With the fix, the panels report distinct ids, both render, and the console is clean. Unmounting one leaves the other fully intact.

The behaviour this changes

Worth deciding on explicitly before merging.

Components mounted outside an <EmbedPDF> stop resolving a registry. Today a toolbar sitting next to <EmbedPDF> rather than inside it can call useZoomCapability() and it works, because the object is global. After this change it gets a frozen fallback that nothing writes to, so it sees registry: null and isLoading: true, and this warning:

[@embedpdf/core] useRegistry() was called with no <EmbedPDF> ancestor. It will never
resolve a registry, so plugins and capabilities stay in their loading state. Move the
component inside <EmbedPDF>.

The warning fires on every resolution rather than once. That's deliberate: a module-level "warn once" flag would be shared across SSR requests — the same cross-instance leak this PR removes — and would silence the warning for every request after the first on a long-running server.

React degrades the same way (default context, never ready); Vue throws. Happy to switch to throwing if you'd rather match Vue — say the word and I'll push it.

useRegistry(), useCoreState() and usePlugin() must now be called during component initialization, as getContext requires. Narrower than it sounds: useCapability calls $effect internally, so it already throws effect_orphan outside an init context. Only the three direct hooks change. Every plugin hook in the repo calls them at the top of its own function body, so none of them move.

pdfContext is still exported so existing imports resolve, but it is deprecated, frozen, and no longer written to. A consumer still writing to it gets a TypeError instead of silently feeding state to unrelated components.

Notes on the implementation

  • setContext/getContext with a symbol key rather than createContext, which needs Svelte 5.40 while @embedpdf/core declares "svelte": ">=5 <6". Raising that floor felt like your call.
  • The one-shot const { registry } = … read in usePlugin is left as it was. This PR scopes the context; it doesn't change when plugins resolve.
  • Context is already used elsewhere in the tree (plugin-viewport sets viewport-element, plugin-ui and plugin-annotation keep registries), so this introduces no new mechanism.
  • Changeset is a patch on @embedpdf/core. Given the out-of-tree behaviour change you may prefer minor — your call, happy to bump it.

Verified

  • pnpm --filter @embedpdf/core test — 6/6 pass; 6/6 fail with the old implementation swapped back in
  • pnpm --filter @embedpdf/core build — all five modes (base, react, preact, vue, svelte) exit 0
  • svelte-check on examples/svelte-tailwind reports nothing from the new route and nothing touching this change
  • prettier --check clean on every file touched
  • eslint src/svelte reports the same 16 findings before and after — no new ones (the $state/console "not defined" errors are the existing rune/browser-globals gap in the config, which already hits src/shared/components/embed-pdf.tsx)

The second commit is optional

docs(example-svelte) is separate on purpose. Drop it if you'd rather not carry a demo route — the fix and its tests stand on their own.

Related

#520 touches the same branch in EmbedPDF.svelte and interacts with this. The branch swap on pluginsReady is what makes the bug visible, but it is also the only thing that lets a Svelte usePlugin consumer pick up a registry at all, because it destructures once and never re-reads. Removing the remount without making that read reactive would leave consumers that initialize before the registry lands stuck on isLoading: true. Flagging it so the two aren't fixed independently.

RemiClaeys and others added 2 commits August 13, 2026 18:04
The Svelte adapter kept its context in one module-level $state object that
every consumer read, so two <EmbedPDF> instances on a page overwrote each
other's registry, coreState and activeDocumentId, and unmounting either one
reset both to their empty state.

Each instance now creates its own context and publishes it with setContext,
which is how the React, Preact and Vue adapters already scope theirs. usePlugin
resolves through useRegistry() instead of importing the module object, matching
the other adapters. The context key uses Symbol.for so two accidentally bundled
copies of @embedpdf/core still agree on it.

Behaviour changes:

- Components with no <EmbedPDF> ancestor previously reached the global object
  and worked by accident. They now get an inert, frozen fallback and a console
  warning on every resolution. The warning is deliberately not once-only: a
  module-level flag would be shared across SSR requests, silencing it for every
  request after the first.
- useRegistry, useCoreState and usePlugin must be called during component
  initialization, as getContext requires. useCapability already had this
  constraint through its internal $effect.
- pdfContext stays exported so existing imports resolve, but it is deprecated,
  frozen, and no longer written to; writes now throw instead of leaking into
  other consumers.

Adds a vitest suite (the package had no test setup) covering instance
isolation, snippet/descendant resolution, sibling-unmount survival, and the
orphan fallback path. All six tests fail against the previous implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmxLnPJcJaA8s885gY4rgZ
Mounts two independent <EmbedPDF> instances side by side, each with its own
registry and document, plus a toggle that unmounts the second one. Each panel
prints the context it resolved, so the isolation is visible without opening the
devtools.

Against the previous module-level context both panels report the same
activeDocumentId and render nothing, and the console shows "Cannot register
viewport for <id>: document state not found".

A third probe sits outside every <EmbedPDF> to show the one case the fix
changes: it resolves the inert fallback and warns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmxLnPJcJaA8s885gY4rgZ
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the CloudPDF Team on Vercel.

A member of the Team first needs to authorize it.

@claeyzre
claeyzre marked this pull request as ready for review September 1, 2026 07:45
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