diff --git a/client/src/controllers/BlockController.test.js b/client/src/controllers/BlockController.test.js
new file mode 100644
index 0000000000..308fb004d3
--- /dev/null
+++ b/client/src/controllers/BlockController.test.js
@@ -0,0 +1,125 @@
+import { Application } from '@hotwired/stimulus';
+import { BlockController } from './BlockController';
+
+const render = jest.fn();
+const unpack = jest.fn(() => ({ render }));
+window.telepath = { unpack };
+
+describe('BlockController', () => {
+ const eventNames = ['w-block:ready'];
+
+ const events = {};
+
+ eventNames.forEach((name) => {
+ document.addEventListener(name, (event) => {
+ events[name].push(event);
+ });
+ });
+
+ let application;
+ let errors = [];
+
+ const setup = (html, { identifier = 'w-block' } = {}) => {
+ document.body.innerHTML = `${html}`;
+
+ application = new Application();
+
+ application.register(identifier, BlockController);
+
+ application.handleError = (error, message) => {
+ errors.push({ error, message });
+ };
+
+ application.start();
+
+ return Promise.resolve();
+ };
+
+ beforeEach(() => {
+ application?.stop();
+ document.body.innerHTML = '';
+ errors = [];
+ eventNames.forEach((name) => {
+ events[name] = [];
+ });
+ jest.clearAllMocks();
+ });
+
+ it('does nothing if block element is not found', async () => {
+ await setup('
');
+
+ expect(errors).toHaveLength(0);
+
+ expect(unpack).not.toHaveBeenCalled();
+
+ expect(events['w-block:ready']).toHaveLength(0);
+ });
+
+ it('should render block if element is controlled', async () => {
+ const data = { _args: ['...'], _type: 'wagtail.blocks.StreamBlock' };
+
+ await setup(
+ `
+
`,
+ );
+
+ expect(errors).toHaveLength(0);
+ expect(unpack).toHaveBeenCalledWith(data);
+ expect(render).toHaveBeenCalledWith(
+ document.getElementById('my-element'),
+ 'my-element',
+ );
+ expect(events['w-block:ready']).toHaveLength(1);
+ });
+
+ it('should call the unpacked render function with provided initial & error data', async () => {
+ const data = { _args: ['...'], _type: 'wagtail.blocks.StreamBlock' };
+ const initialData = [{ type: 'paragraph_block', value: '...' }];
+ const errorData = { messages: ['An error...'] };
+
+ await setup(
+ `
+
`,
+ );
+
+ expect(errors).toHaveLength(0);
+ expect(unpack).toHaveBeenCalledWith(data);
+ expect(render).toHaveBeenCalledWith(
+ document.getElementById('my-element'),
+ 'my-element',
+ initialData,
+ errorData,
+ );
+ expect(events['w-block:ready']).toHaveLength(1);
+ });
+
+ it('should throw an error if used on an element without an id', async () => {
+ await setup('');
+
+ expect(errors).toHaveLength(1);
+ expect(errors).toHaveProperty(
+ '0.error.message',
+ 'Controlled element needs an id attribute.',
+ );
+ });
+
+ it('should throw an error if Telepath is not available in the window global', async () => {
+ delete window.telepath;
+ await setup('');
+
+ expect(errors).toHaveLength(1);
+ expect(errors).toHaveProperty(
+ '0.error.message',
+ '`window.telepath` is not available.',
+ );
+ });
+});
diff --git a/client/src/controllers/BlockController.ts b/client/src/controllers/BlockController.ts
new file mode 100644
index 0000000000..a240acf382
--- /dev/null
+++ b/client/src/controllers/BlockController.ts
@@ -0,0 +1,86 @@
+import { Controller } from '@hotwired/stimulus';
+
+declare global {
+ interface Window {
+ initBlockWidget?: (id: string) => void;
+ telepath: any;
+ }
+}
+
+/**
+ * Adds the ability to unpack a Telepath object and render it on the controlled element.
+ * Used to initialize the top-level element of a BlockWidget (the form widget for a StreamField).
+ *
+ * @example
+ *
+ *
+ *
+ * @example - with initial arguments
+ *
+ *
+ */
+export class BlockController extends Controller {
+ static values = {
+ arguments: { type: Array, default: [] },
+ data: { type: Object, default: {} },
+ };
+
+ /** Array of arguments to pass to the render method of the block [initial value, errors]. */
+ declare argumentsValue: Array;
+ /** Block definition to be passed to `telepath.unpack`, used to obtain a JavaScript representation of the block. */
+ declare dataValue: object;
+
+ connect() {
+ const telepath = window.telepath;
+
+ if (!telepath) {
+ throw new Error('`window.telepath` is not available.');
+ }
+
+ const element = this.element;
+ const id = element.id;
+
+ if (!id) {
+ throw new Error('Controlled element needs an id attribute.');
+ }
+
+ const output = telepath.unpack(this.dataValue);
+ output.render(element, id, ...this.argumentsValue);
+ this.dispatch('ready', { detail: { ...output }, cancelable: false });
+ }
+
+ static afterLoad() {
+ /**
+ * Provide a backwards compatible version of the original window global function.
+ *
+ * @deprecated RemovedInWagtail70
+ */
+ window.initBlockWidget = (id: string) => {
+ const body = document.querySelector(
+ '#' + id + '[data-block]',
+ ) as HTMLElement;
+
+ if (!body) {
+ return;
+ }
+
+ const blockDefData = JSON.parse(body.dataset.data as string);
+ if (window.telepath) {
+ const blockDef = window.telepath.unpack(blockDefData);
+ const blockValue = JSON.parse(body.dataset.value as string);
+ const blockError = JSON.parse(body.dataset.error as string);
+
+ blockDef.render(body, id, blockValue, blockError);
+ }
+ };
+ }
+}
diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts
index a5635fbb87..1a19366bb8 100644
--- a/client/src/controllers/index.ts
+++ b/client/src/controllers/index.ts
@@ -3,6 +3,7 @@ import type { Definition } from '@hotwired/stimulus';
// Order controller imports alphabetically.
import { ActionController } from './ActionController';
import { AutosizeController } from './AutosizeController';
+import { BlockController } from './BlockController';
import { BulkController } from './BulkController';
import { ClipboardController } from './ClipboardController';
import { CloneController } from './CloneController';
@@ -34,6 +35,7 @@ export const coreControllerDefinitions: Definition[] = [
// Keep this list in alphabetical order
{ controllerConstructor: ActionController, identifier: 'w-action' },
{ controllerConstructor: AutosizeController, identifier: 'w-autosize' },
+ { controllerConstructor: BlockController, identifier: 'w-block' },
{ controllerConstructor: BulkController, identifier: 'w-bulk' },
{ controllerConstructor: ClipboardController, identifier: 'w-clipboard' },
{ controllerConstructor: CloneController, identifier: 'w-clone' },