Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create option immutable, implement support for mutable changes #332

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,28 @@ mode: 'tree' | 'text' | 'table'

Open the editor in `'tree'` mode (default), `'table'` mode, or `'text'` mode (formerly: `code` mode).

#### immutable

```ts
immutable: boolean
```

The option `immutable` is `false` by default. It is higly recommended to configure the editor with `immmutable: true` and only make changes to the editor's contents in an immutable way. This gives _much_ better performane, and is a necessity when working with large JSON documents.

How to use the library in an immutable way, and why, is explained in detail in the section [Immutability](#immutability).

#### immutableWarningDisabled

```ts
immutableWarningDisabled: boolean
```

The option `immutableWarningDisabled` is `false` by default. When `svelte-jsoneditor` is configured with `{immutable: false}`, it will log the following console warning:

> JSONEditor is configured with {immutable:false}, which is bad for performance. Consider configuring {immutable:true}, or disable this warning by configuring {immutableWarningDisabled:true}.

If you really need support for mutable changes, you can suppress this warning by configuring `{immutableWarningDisabled: true}`.

#### mainMenuBar

```ts
Expand Down Expand Up @@ -937,6 +959,75 @@ When updating CSS variables dynamically, it is necessary to refresh the via `edi
<JSONEditor bind:this="{editorRef}" ... />
```

## Immutability

> TL;DR configure `svelte-jsoneditor` with `{immutable: true}` and only make immutable changes to you document contents for best performance. If you _do_ need support for mutable changes, you can disable the console warning by configuring `{immutableWarningDisabled: true}`.

The editor can support both _immutable_ and _mutable_ changes made to the contents of the editor. This can be configured with the option [`immutable`](#immutable). It is strongly recommended to configure the editor with `{immmutable: true}` and only make changes to the editor's contents in an _immutable_ way. This gives much better performance, and is a necessity when working with large JSON documents.

If you're making mutable changes, the editor has to make a full copy of the JSON document on every change to enable history (undo/redo) and to provide the `onChange` callback with both a current and previous version of the document. The editor also has to do a full rerender of the UI on every change, since it cannot know which part of the document has been changed.

When using immutable changes on the other hand, the editor knows exactly which part of the document is changed using a cheap strict equal check against the previous version of the data. There is no need to make a deep copy of the document on changes, and also, the change detection can be used to only rerender the parts of the UI that actually changed.

Here is an example of a mutable change:

```js
// mutable change (NOT RECOMMENDED)
function updateDate() {
const lastEdited = new Date().toISOString()
const content = toJsonContent(myJsonEditor.get())
content.json.lastEdited = lastEdited // <- this is a mutable change
myJsonEditor.update(content)
}
```

Instead, you can apply the same change in an immutable way. There are various options for that:

```js
// immutable change (RECOMMENDED)

// immutable change using a libary like "mutative" or "immer" (efficient and easy to work with)
import { create } from 'mutative'
function updateDate1() {
const content = toJsonContent(myJsonEditor.get())
const updatedContent = create(content, (draft) => {
draft.json.lastEdited = new Date().toISOString()
})
myJsonEditor.update(updatedContent)
}

// immutable change using "immutable-json-patch"
import { setIn } from 'immutable-json-patch'
function updateDate2() {
const content = toJsonContent(myJsonEditor.get())
const updatedContent = setIn(content, ['json', 'lastEdited'], new Date().toISOString())
myJsonEditor.update(updatedContent)
}

// immutable change using the spread operator (not handy for updates in nested data)
function updateDate3() {
const content = toJsonContent(myJsonEditor.get())
const updatedContent = {
json: {
...content.json,
lastEdited: new Date().toISOString()
}
}
myJsonEditor.update(updatedContent)
}

// immutable change by creating a deep clone (simple but inefficient)
import { cloneDeep } from 'lodash-es'
function updateDate4() {
const content = toJsonContent(myJsonEditor.get())
const updatedContent = cloneDeep(content)
updatedContent.json.lastEdited = new Date().toISOString()
myJsonEditor.update(updatedContent)
}
```

Besides performance benefits, another advantage of an immutable way of working is that it makes the data that you work with much more predictive and less error-prone. You can learn more about immutability by searching for articles or videos about the subject, such as [this video](https://youtu.be/Wo0qiGPSV-s) or [this article](https://www.freecodecamp.org/news/immutability-in-javascript-with-examples/). Immutability is not always the best choice, but in the case of this JSON Editor we're dealing with large and deeply nested data structures, in which we typically make only small changes like updating a single nested value. An immutable approach really shines here, enabling `svelte-jsoneditor` to smoothly render and edit JSON documents up to 512 MB.

## Differences between `josdejong/svelte-jsoneditor` and `josdejong/jsoneditor`

This library [`josdejong/svelte-jsoneditor`](https:/josdejong/svelte-jsoneditor/) is the successor of [`josdejong/jsoneditor`](https:/josdejong/jsoneditor). The main differences are:
Expand Down
26 changes: 19 additions & 7 deletions src/lib/components/JSONEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@
export let content: Content = { text: '' }
export let selection: JSONEditorSelection | null = null

export let immutable = false
export let immutableWarningDisabled = false
export let readOnly = false
export let indentation: number | string = 2
export let tabSize = 4
Expand Down Expand Up @@ -112,6 +114,17 @@
callbacks: Partial<Callbacks>
} | null = null

$: {
if (!immutable && !immutableWarningDisabled) {
console.warn(
'JSONEditor is configured with {immutable:false}, which is bad for performance. ' +
'Consider configuring {immutable:true}, ' +
'or disable this warning by configuring {immutableWarningDisabled:true}. ' +
'Read more: https:/josdejong/svelte-jsoneditor/?tab=readme-ov-file#immutability'
)
}
}

$: {
const contentError = validateContentType(content)
if (contentError) {
Expand Down Expand Up @@ -160,8 +173,7 @@
// new editor id -> will re-create the editor
instanceId = uniqueId()

// update content *after* re-render, so that the new editor will trigger an onChange event
content = newContent
content = immutable ? newContent : { ...newContent }
}

export async function update(updatedContent: Content): Promise<void> {
Expand All @@ -172,7 +184,7 @@
throw new Error(contentError)
}

content = updatedContent
content = immutable ? updatedContent : { ...updatedContent }

await tick() // await rerender
}
Expand Down Expand Up @@ -263,16 +275,14 @@
}

export async function updateProps(props: JSONEditorPropsOptional): Promise<void> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error TS doesn't understand the .$set() method
this.$set(props)

await tick() // await rerender
}

export async function destroy() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error TS doesn't understand the .$destroy() method
this.$destroy()

await tick() // await destroying
Expand Down Expand Up @@ -395,6 +405,7 @@
path,
onPatch,

immutable,
readOnly,
indentation,
tabSize,
Expand Down Expand Up @@ -456,6 +467,7 @@
{mode}
{content}
{selection}
{immutable}
{readOnly}
{indentation}
{tabSize}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/components/modals/JSONEditorModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
export let path: JSONPath
export let onPatch: OnPatch

export let immutable: boolean
export let readOnly: boolean
export let indentation: number | string
export let tabSize: number
Expand Down Expand Up @@ -237,6 +238,7 @@
mode={currentState.mode}
content={currentState.content}
selection={currentState.selection}
{immutable}
{readOnly}
{indentation}
{tabSize}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/components/modals/TransformModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@
<TreeMode
externalContent={selectedContent}
externalSelection={null}
immutable={true}
readOnly={true}
mainMenuBar={false}
navigationBar={false}
Expand Down Expand Up @@ -312,6 +313,7 @@
<TreeMode
externalContent={previewContent}
externalSelection={null}
immutable={true}
readOnly={true}
mainMenuBar={false}
navigationBar={false}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/components/modes/JSONEditorRoot.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
export let content: Content
export let selection: JSONEditorSelection | null

export let immutable: boolean
export let readOnly: boolean
export let indentation: number | string
export let tabSize: number
Expand Down Expand Up @@ -274,6 +275,7 @@
bind:this={refTableMode}
externalContent={content}
externalSelection={selection}
{immutable}
{readOnly}
{mainMenuBar}
{escapeControlCharacters}
Expand Down Expand Up @@ -302,6 +304,7 @@
bind:this={refTreeMode}
externalContent={content}
externalSelection={selection}
{immutable}
{readOnly}
{indentation}
{mainMenuBar}
Expand Down
58 changes: 41 additions & 17 deletions src/lib/components/modes/tablemode/TableMode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
const isSSR = typeof window === 'undefined'
debug('isSSR:', isSSR)

export let immutable: boolean
export let readOnly: boolean
export let externalContent: Content
export let externalSelection: JSONEditorSelection | null
Expand Down Expand Up @@ -253,7 +254,7 @@
// no vertical scroll, the actual scrollTop changes to 0 but there is no on:scroll event
// triggered, so the internal scrollTop variable is not up-to-date.
// This is a workaround to update the scrollTop by triggering an on:scroll event
if (refContents) {
if (refContents?.scrollTo) {
refContents.scrollTo({
top: refContents.scrollTop,
left: refContents.scrollLeft
Expand Down Expand Up @@ -362,7 +363,9 @@
const currentContent = { json }
const isChanged = isTextContent(content)
? content.text !== text
: !isEqual(currentContent.json, content.json)
: immutable
? currentContent.json !== content.json
: !isEqual(currentContent.json, content.json)

debug('update external content', { isChanged })

Expand Down Expand Up @@ -401,7 +404,7 @@
}
}
} else {
json = content.json
json = cloneJsonWhenMutable(content.json)
text = undefined
textIsRepaired = false
parseError = undefined
Expand Down Expand Up @@ -438,6 +441,25 @@
}
}

function cloneWhenMutable(content: Content): Content {
if (isTextContent(content)) {
return content
}

return {
json: cloneJsonWhenMutable(content.json)
}
}

function cloneJsonWhenMutable(json: unknown): unknown {
if (immutable) {
return json
}

debug('cloning content')
return parser.parse(parser.stringify(json) || '')
}

// TODO: addHistoryItem is a duplicate of addHistoryItem in TreeMode.svelte. Can we extract and reuse this logic?
function addHistoryItem({
previousJson,
Expand Down Expand Up @@ -668,22 +690,24 @@
return
}

if (!onChange) {
return
}

// make sure we cannot send an invalid contents like having both
// json and text defined, or having none defined
if (onChange) {
if (text !== undefined) {
const content = { text, json: undefined }
onChange(content, previousContent, {
contentErrors: validate(),
patchResult
})
} else if (json !== undefined) {
const content = { text: undefined, json }
onChange(content, previousContent, {
contentErrors: validate(),
patchResult
})
}
if (text !== undefined) {
const content = { text, json: undefined }
onChange(cloneWhenMutable(content), cloneWhenMutable(previousContent), {
contentErrors: validate(),
patchResult
})
} else if (json !== undefined) {
const content = { text: undefined, json }
onChange(cloneWhenMutable(content), cloneWhenMutable(previousContent), {
contentErrors: validate(),
patchResult
})
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/modes/textmode/TextMode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import type { JSONPatchDocument, JSONPath } from 'immutable-json-patch'
import { immutableJSONPatch, revertJSONPatch } from 'immutable-json-patch'
import { jsonrepair } from 'jsonrepair'
import { debounce, isEqual, uniqueId } from 'lodash-es'
import { debounce, uniqueId } from 'lodash-es'
import { onDestroy, onMount, tick } from 'svelte'
import {
JSON_STATUS_INVALID,
Expand Down Expand Up @@ -673,7 +673,7 @@

function setCodeMirrorContent(newContent: Content, forceUpdate = false) {
const newText = getText(newContent, indentation, parser)
const isChanged = !isEqual(newContent, content)
const isChanged = newText !== text
const previousContent = content

debug('setCodeMirrorContent', { isChanged, forceUpdate })
Expand Down
Loading
Loading