daffie / canvas_revisions
Revision handling for Drupal Canvas content on the PostgreSQL with JSON entity storage.
Package info
gitlab.com/daffie/canvas_revisions
Type:drupal-module
pkg:composer/daffie/canvas_revisions
Requires
- daffie/pgsql: *
- drupal/canvas: ^1.3.2
- drupal/core: >=11.3 <12
This package is auto-updated.
Last update: 2026-07-22 08:32:34 UTC
README
Adds a Revisions side panel to the Drupal Canvas editor: browse an entity's revision history, see which one the editor is currently showing, and load an older revision back into the editor to restore it.
It is built for the daffie/pgsql JSONB entity
storage driver, and depends on both canvas and pgsql. On any other database driver
the module installs but does nothing — the storage overrides that make revision listing
cheap only exist on pgsql (see Entity types and storage).
- Requires: Drupal core
^11.3,drupal/canvas ^1.3.2,daffie/pgsql. - Configure: Administration › Configuration › Content authoring › Canvas revisions
(
/admin/config/content/canvas-revisions), permissionadminister canvas revisions.
Why it is not just a React panel
Canvas's editor is a single-page React app that ships as a ~4 MB prebuilt bundle, and its
side rail and panel switch are two hardcoded lists in its source
(SideMenu.tsx / PrimaryPanel.tsx) with no registration API. Adding a real React panel
would mean patching contrib and rebuilding that bundle on every update.
So the panel is a plain vanilla-JS overlay instead:
- The rail button is declared as a page extension
(
canvas_revisions.canvas_extension.yml), which Canvas renders for us. js/panel.jsintercepts that button's click and mounts its own panel as a flex sibling of Canvas's primary panel — same slot, same dimensions, same open/close transition — so the two behave as mutually exclusive views of one slot.- The panel library is made a dependency of
canvas-ui(LibraryHooks::libraryInfoAlter()), which is what gets the script onto every page where the editor boots.
extension/index.html is a fallback page, reached only by deep-linking to
/canvas/app/canvas_revisions directly; normally the click never gets that far.
How loading a revision works
Canvas has no concept of "open an arbitrary revision": the editor always renders the
default revision overlaid with the draft its auto-save layer holds. So loading a revision
does not try to make the editor show something it cannot — it writes the revision into
that draft (RevisionListController::load()) and reloads the editor, which then
displays it through its normal path. Publishing that draft persists it, which is what
restoring an old revision means.
Two problems fall out of that, and most of the module exists to solve them.
1. Telling a "loaded revision" apart from real unsaved work
A draft that is just a revision someone loaded looks identical to a draft that is work someone did — both are the same object in the auto-save store. Left unaddressed, browsing the history would make every page look like it had unpublished changes waiting, and offer them up for publishing.
LoadedRevisionMarker records, per entity, the revision a draft was loaded from and the
draft's hash at that moment. A draft that still hashes to what the module wrote is a
revision being viewed; anything else — including the moment the user edits it — is their
work. That one distinction drives three things:
- which row the panel highlights as Editing (
RevisionListBuilder), - whether replacing the draft needs a "you'll lose your changes" warning (
js/panel.js), - whether Canvas's "Review changes" list counts the draft as a pending change
(
PendingChangesController, which filters Canvas's own auto-save endpoint).
A marker is only meaningful while its draft exists. Rather than trying to observe every
way a draft can end, markers are pruned self-correctingly: LoadedRevisionMarkerPruner
drops stale ones on any auto-save write (it listens to Canvas's auto-save cache tag), and
RevisionListBuilder clears one the moment it notices the draft is gone. hook_uninstall
deletes the rest, since they live in the key-value store which nothing else reaps.
2. The changed timestamp
An old revision carries an old changed time, and unlike the entity keys that is an
ordinary field Canvas copies onto the stored entity when the draft is published — at which
point core sees an older timestamp than storage and refuses the save as a concurrent edit.
RevisionListController::alignChangedTime() gives the drafted revision the stored
entity's changed time (per translation, the granularity core validates at), because the
draft is based on the entity as it stands now; restoring old content does not move the
entity back in time.
Entity types and storage
Listing a revision's summary without materializing a full entity per row is what makes a long history cheap to display. That reads directly out of the JSONB full-history document the pgsql driver already holds, so it only exists on that driver.
EntityHooks::entityTypeAlter()points the Canvas-editable entity types —canvas_pageandnode— at storage handlers whoseloadAll()returns a revision-listing history handle. It is a no-op unless the pgsql driver is active, and touches no other entity type.CanvasPageStorage/CanvasNodeStorageare thin subclasses (viaRevisionListStorageTrait) of the storage each type would otherwise use — the node one extends pgsql's ownNodeStorageso node's extra API is not lost.CanvasRevisionEntity::getRevisionList()builds the summary rows from the history document, falling back to materializing a revision only when it cannot (e.g. an entity class that overrideslabel()). Usernames are resolved in one batched load.
An entity type without this storage answers the listing endpoint with a 501, which the
panel turns into a plain "revisions are not available for this kind of content" message.
Route swaps
Routing\RouteSubscriber wraps — never replaces — two of Canvas's own endpoints; each
wrapper takes Canvas's controller as a dependency and is tolerant of it going missing, so
an upstream rename degrades to Canvas's own behaviour rather than a broken editor:
canvas.boot.empty/canvas.boot.entity→EditorBootController, which attaches the open entity's revision history to the editor page that is loading anyway, so the panel's first open needs no request of its own. Both boot routes are swapped because Canvas rewrites every/canvas/*path to/canvasbefore routing, so the editor is actually served bycanvas.boot.emptywith no parameters — the open entity survives only in the browser URL, which is why both the controller and the panel read it from the path. The preloaded rows carry their cacheability with them, and the response is varied byurl.pathso Dynamic Page Cache cannot serve one entity's history for another's.canvas.api.auto-save.get→PendingChangesController, the change-list filter described above.
Configuration
| Setting | Default | Effect |
|---|---|---|
revision_limit | 50 | How many of the newest revisions the panel lists. Listing a revision can cost materializing it, so this bounds the editor-page cost on content with a long history. 0 means no limit. |
Layout
canvas_revisions.canvas_extension.yml Rail button, as a Canvas page extension
js/panel.js The panel: mount, list, load, freshness
css/panel.css Panel styling
src/Controller/
EditorBootController.php Preloads history into the editor page
RevisionListController.php The list + load endpoints
PendingChangesController.php Hides loaded revisions from the change list
src/Entity/CanvasRevisionEntity.php Revision-listing history handle (pgsql)
src/EntityStorage/ Storage handlers that return that handle
src/LoadedRevisionMarker.php "This draft is a loaded revision" tracking
src/Cache/LoadedRevisionMarkerPruner.php Prunes stale markers on auto-save writes
src/RevisionListBuilder.php Builds the rows both paths render
src/Hook/ entity_type_alter, library_info_alter
src/Routing/RouteSubscriber.php The two route swaps
src/Form/SettingsForm.php The settings form
Tests
PHP kernel tests live in tests/src/Kernel/ and cover the storage swap, the listing and
its cap, revision-load ownership, the changed-time alignment, the pending-changes
filter, marker pruning, and the editor preload (including its cacheability). They run only
against the pgsql driver and skip otherwise.
js/panel.js has no test that ships with the module yet — see the review notes in the
repository root for the current state of that gap.