diff --git a/core/requires.js b/core/requires.js
index 2ca295441..087ad8ac0 100644
--- a/core/requires.js
+++ b/core/requires.js
@@ -82,3 +82,5 @@ goog.require('Blockly.zelos.Renderer');
// Blockly Themes.
// Classic is the default theme.
goog.require('Blockly.Themes.Classic');
+
+goog.require('Blockly.serialization.blocks');
diff --git a/core/serialization/blocks.js b/core/serialization/blocks.js
new file mode 100644
index 000000000..cefdb3317
--- /dev/null
+++ b/core/serialization/blocks.js
@@ -0,0 +1,114 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Handles serializing blocks to plain JavaScript objects only
+ * containing state.
+ */
+'use strict';
+
+goog.module('Blockly.serialization.blocks');
+goog.module.declareLegacyNamespace();
+
+
+// TODO: Remove this once lint is fixed.
+/* eslint-disable no-use-before-define */
+
+/**
+ * Represents the state of a given block.
+ * @typedef {{
+ * type: string,
+ * id: string,
+ * x: (number|undefined),
+ * y: (number|undefined),
+ * collapsed: (boolean|undefined),
+ * disabled: (boolean|undefined),
+ * editable: (boolean|undefined),
+ * deletable: (boolean|undefined),
+ * movable: (boolean|undefined),
+ * inline: (boolean|undefined),
+ * data: (string|undefined),
+ * }}
+ */
+var State;
+exports.State = State;
+
+/**
+ * Returns the state of the given block as a plain JavaScript object.
+ * @param {!Blockly.Block} block The block to serialize.
+ * @param {{addCoordinates: (boolean|undefined)}=} param1
+ * addCoordinates: If true the coordinates of the block are added to the
+ * serialized state. False by default.
+ * @return {?State} The serialized state of the
+ * block, or null if the block could not be serialied (eg it was an
+ * insertion marker).
+ */
+const save = function(block, {addCoordinates = false} = {}) {
+ if (block.isInsertionMarker()) {
+ return null;
+ }
+
+ const state = {
+ 'type': block.type,
+ 'id': block.id
+ };
+
+ if (addCoordinates) {
+ addCoords(block, state);
+ }
+ addAttributes(block, state);
+
+ return state;
+};
+exports.save = save;
+
+/**
+ * Adds attributes to the given state object based on the state of the block.
+ * Eg collapsed, disabled, editable, etc.
+ * @param {!Blockly.Block} block The block to base the attributes on.
+ * @param {!State} state The state object to append
+ * to.
+ */
+const addAttributes = function(block, state) {
+ if (block.isCollapsed()) {
+ state['collapsed'] = true;
+ }
+ if (!block.isEnabled()) {
+ state['enabled'] = false;
+ }
+ if (!block.isEditable()) {
+ state['editable'] = false;
+ }
+ if (!block.isDeletable() && !block.isShadow()) {
+ state['deletable'] = false;
+ }
+ if (!block.isMovable() && !block.isShadow()) {
+ state['movable'] = false;
+ }
+
+ if (block.inputsInline !== undefined &&
+ block.inputsInline !== block.inputsInlineDefault) {
+ state['inline'] = block.inputsInline;
+ }
+
+ // Data is a nullable string, so we don't need to worry about falsy values.
+ if (block.data) {
+ state['data'] = block.data;
+ }
+};
+
+/**
+ * Adds the coordinates of the given block to the given state object.
+ * @param {!Blockly.Block} block The block to base the coordinates on
+ * @param {!State} state The state object to append
+ * to
+ */
+const addCoords = function(block, state) {
+ const workspace = block.workspace;
+ const xy = block.getRelativeToSurfaceXY();
+ state['x'] = Math.round(workspace.RTL ? workspace.getWidth() - xy.x : xy.x);
+ state['y'] = Math.round(xy.y);
+};
diff --git a/core/serialization/serialization.js b/core/serialization/serialization.js
new file mode 100644
index 000000000..c53a2eb64
--- /dev/null
+++ b/core/serialization/serialization.js
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Contains top-level functions for serialization of the workspace
+ * to JavaScript objects.
+ */
+'use strict';
+
+/**
+ * The top level namespace for JavaScript Object serialization.
+ * @namespace Blockly.serialization
+ */
+goog.module('Blockly.serialization');
+goog.module.declareLegacyNamespace();
diff --git a/tests/deps.js b/tests/deps.js
index 4cb0f6fd3..7c8f2c7cc 100644
--- a/tests/deps.js
+++ b/tests/deps.js
@@ -151,8 +151,10 @@ goog.addDependency('../../core/renderers/zelos/measurables/row_elements.js', ['B
goog.addDependency('../../core/renderers/zelos/measurables/rows.js', ['Blockly.zelos.BottomRow', 'Blockly.zelos.TopRow'], ['Blockly.blockRendering.BottomRow', 'Blockly.blockRendering.TopRow', 'Blockly.utils.object']);
goog.addDependency('../../core/renderers/zelos/path_object.js', ['Blockly.zelos.PathObject'], ['Blockly.blockRendering.PathObject', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.zelos.ConstantProvider']);
goog.addDependency('../../core/renderers/zelos/renderer.js', ['Blockly.zelos.Renderer'], ['Blockly.InsertionMarkerManager', 'Blockly.blockRendering', 'Blockly.blockRendering.Renderer', 'Blockly.connectionTypes', 'Blockly.constants', 'Blockly.utils.object', 'Blockly.zelos.ConstantProvider', 'Blockly.zelos.Drawer', 'Blockly.zelos.MarkerSvg', 'Blockly.zelos.PathObject', 'Blockly.zelos.RenderInfo']);
-goog.addDependency('../../core/requires.js', ['Blockly.requires'], ['Blockly', 'Blockly.Comment', 'Blockly.ContextMenuItems', 'Blockly.FieldAngle', 'Blockly.FieldCheckbox', 'Blockly.FieldColour', 'Blockly.FieldDropdown', 'Blockly.FieldImage', 'Blockly.FieldLabelSerializable', 'Blockly.FieldMultilineInput', 'Blockly.FieldNumber', 'Blockly.FieldTextInput', 'Blockly.FieldVariable', 'Blockly.FlyoutButton', 'Blockly.Generator', 'Blockly.HorizontalFlyout', 'Blockly.Mutator', 'Blockly.ShortcutItems', 'Blockly.Themes.Classic', 'Blockly.Toolbox', 'Blockly.Trashcan', 'Blockly.VariablesDynamic', 'Blockly.VerticalFlyout', 'Blockly.Warning', 'Blockly.ZoomControls', 'Blockly.geras.Renderer', 'Blockly.thrasos.Renderer', 'Blockly.zelos.Renderer']);
+goog.addDependency('../../core/requires.js', ['Blockly.requires'], ['Blockly', 'Blockly.Comment', 'Blockly.ContextMenuItems', 'Blockly.FieldAngle', 'Blockly.FieldCheckbox', 'Blockly.FieldColour', 'Blockly.FieldDropdown', 'Blockly.FieldImage', 'Blockly.FieldLabelSerializable', 'Blockly.FieldMultilineInput', 'Blockly.FieldNumber', 'Blockly.FieldTextInput', 'Blockly.FieldVariable', 'Blockly.FlyoutButton', 'Blockly.Generator', 'Blockly.HorizontalFlyout', 'Blockly.Mutator', 'Blockly.ShortcutItems', 'Blockly.Themes.Classic', 'Blockly.Toolbox', 'Blockly.Trashcan', 'Blockly.VariablesDynamic', 'Blockly.VerticalFlyout', 'Blockly.Warning', 'Blockly.ZoomControls', 'Blockly.geras.Renderer', 'Blockly.serialization.blocks', 'Blockly.thrasos.Renderer', 'Blockly.zelos.Renderer']);
goog.addDependency('../../core/scrollbar.js', ['Blockly.Scrollbar', 'Blockly.ScrollbarPair'], ['Blockly.Events', 'Blockly.Touch', 'Blockly.browserEvents', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.Metrics', 'Blockly.utils.Svg', 'Blockly.utils.dom']);
+goog.addDependency('../../core/serialization/blocks.js', ['Blockly.serialization.blocks'], [], {'lang': 'es6', 'module': 'goog'});
+goog.addDependency('../../core/serialization/serialization.js', ['Blockly.serialization'], [], {'module': 'goog'});
goog.addDependency('../../core/shortcut_items.js', ['Blockly.ShortcutItems'], ['Blockly.Gesture', 'Blockly.ShortcutRegistry', 'Blockly.utils.KeyCodes']);
goog.addDependency('../../core/shortcut_registry.js', ['Blockly.ShortcutRegistry'], ['Blockly.utils.KeyCodes', 'Blockly.utils.object']);
goog.addDependency('../../core/theme.js', ['Blockly.Theme'], ['Blockly.registry', 'Blockly.utils', 'Blockly.utils.object']);
diff --git a/tests/mocha/index.html b/tests/mocha/index.html
index ef3384da9..8bc314f96 100644
--- a/tests/mocha/index.html
+++ b/tests/mocha/index.html
@@ -84,6 +84,7 @@
+
diff --git a/tests/mocha/jso_serialization_test.js b/tests/mocha/jso_serialization_test.js
new file mode 100644
index 000000000..730141cd5
--- /dev/null
+++ b/tests/mocha/jso_serialization_test.js
@@ -0,0 +1,236 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+suite('JSO', function() {
+ setup(function() {
+ sharedTestSetup.call(this);
+ this.workspace = new Blockly.Workspace();
+
+ defineStackBlock();
+ defineRowBlock();
+ defineStatementBlock();
+
+ createGenUidStubWithReturns(new Array(10).fill().map((_, i) => 'id' + i));
+ });
+
+ teardown(function() {
+ workspaceTeardown.call(this, this.workspace);
+ sharedTestTeardown.call(this);
+ });
+
+ suite('Blocks', function() {
+ test('Null on insertionMarkers', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setInsertionMarker(true);
+ const jso = Blockly.serialization.blocks.save(block);
+ chai.assert.isNull(jso);
+ });
+
+ suite('Save Single Block', function() {
+
+ function assertProperty(obj, property, value) {
+ chai.assert.equal(obj[property], value);
+ }
+
+ function assertNoProperty(obj, property) {
+ assertProperty(obj, property, undefined);
+ }
+
+ test('Basic', function() {
+ const block = this.workspace.newBlock('row_block');
+ const jso = Blockly.serialization.blocks.save(block);
+ assertProperty(jso, 'type', 'row_block');
+ assertProperty(jso, 'id', 'id0');
+ });
+
+ suite('Attributes', function() {
+ suite('Collapsed', function() {
+ test('True', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setCollapsed(true);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertProperty(jso, 'collapsed', true);
+ });
+
+ test('False', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setCollapsed(false);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'collapsed');
+ });
+ });
+
+ suite('Enabled', function() {
+ test('False', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setEnabled(false);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertProperty(jso, 'enabled', false);
+ });
+
+ test('True', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setEnabled(true);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'enabled');
+ });
+ });
+
+ suite('Deletable', function() {
+ test('False', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setDeletable(false);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertProperty(jso, 'deletable', false);
+ });
+
+ test('True', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setDeletable(true);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'deletable');
+ });
+
+ test('False and Shadow', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setDeletable(false);
+ block.setShadow(true);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'deletable');
+ });
+ });
+
+ suite('Movable', function() {
+ test('False', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setMovable(false);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertProperty(jso, 'movable', false);
+ });
+
+ test('True', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setMovable(true);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'movable');
+ });
+
+ test('False and Shadow', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setMovable(false);
+ block.setShadow(true);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'movable');
+ });
+ });
+
+ suite('Editable', function() {
+ test('False', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setEditable(false);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertProperty(jso, 'editable', false);
+ });
+
+ test('True', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.setEditable(true);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'editable');
+ });
+ });
+
+ suite('Inline', function() {
+ test('True', function() {
+ const block = this.workspace.newBlock('statement_block');
+ block.setInputsInline(true);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertProperty(jso, 'inline', true);
+ });
+
+ test('False', function() {
+ const block = this.workspace.newBlock('statement_block');
+ block.setInputsInline(false);
+ const jso = Blockly.serialization.blocks.save(block);
+ assertProperty(jso, 'inline', false);
+ });
+
+ test('undefined', function() {
+ const block = this.workspace.newBlock('statement_block');
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'inline');
+ });
+
+ test('True, matching default', function() {
+ const block = this.workspace.newBlock('statement_block');
+ block.setInputsInline(true);
+ block.inputsInlineDefault = true;
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'inline');
+ });
+
+ test('False, matching default', function() {
+ const block = this.workspace.newBlock('statement_block');
+ block.setInputsInline(false);
+ block.inputsInlineDefault = false;
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'inline');
+ });
+ });
+
+ suite('Data', function() {
+ test('No data', function() {
+ const block = this.workspace.newBlock('row_block');
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'data');
+ });
+
+ test('With data', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.data = 'some data';
+ const jso = Blockly.serialization.blocks.save(block);
+ assertProperty(jso, 'data', 'some data');
+ });
+ });
+ });
+
+ suite('Coords', function() {
+ test('No coordinates', function() {
+ const block = this.workspace.newBlock('row_block');
+ const jso = Blockly.serialization.blocks.save(block);
+ assertNoProperty(jso, 'x');
+ assertNoProperty(jso, 'y');
+ });
+
+ test('Simple', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.moveBy(42, 42);
+ const jso =
+ Blockly.serialization.blocks.save(block, {addCoordinates: true});
+ assertProperty(jso, 'x', 42);
+ assertProperty(jso, 'y', 42);
+ });
+
+ test('Negative', function() {
+ const block = this.workspace.newBlock('row_block');
+ block.moveBy(-42, -42);
+ const jso =
+ Blockly.serialization.blocks.save(block, {addCoordinates: true});
+ assertProperty(jso, 'x', -42);
+ assertProperty(jso, 'y', -42);
+ });
+
+ test('Zero', function() {
+ const block = this.workspace.newBlock('row_block');
+ const jso =
+ Blockly.serialization.blocks.save(block, {addCoordinates: true});
+ assertProperty(jso, 'x', 0);
+ assertProperty(jso, 'y', 0);
+ });
+ });
+ });
+ });
+});