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: delta updates #1069

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

wusteven815
Copy link
Contributor

@wusteven815 wusteven815 commented Dec 18, 2024

@wusteven815 wusteven815 self-assigned this Dec 18, 2024
@wusteven815 wusteven815 marked this pull request as ready for review December 19, 2024 14:26
Comment on lines 30 to +31
json-rpc
pyjsonpatch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should pin json-rpc and pyjsonpatch. Right now if either of these libraries update with a breaking change, users with new installs may not work correctly.

Suggested change
json-rpc
pyjsonpatch
json-rpc>=1.15.0
pyjsonpatch>=0.1.1

Docs on compatible release notation: https://peps.python.org/pep-0440/#compatible-release

Comment on lines +317 to +319
'connection' in newObj &&
'fetched' in newObj &&
'ticket_0' in newObj
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little sketchy

log.debug2(METHOD_DOCUMENT_PATCHED, params);
const [patch, stateParam] = params;

applyPatch(uiDomRef.current, patch);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally what we should do is only traverse the patch itself to know which elements have changed/need updates - rather than applying the patch and diffing afterwards. I think this can be achieved by iterating through the patch and using applyOperation instead of applyPatch.
I think we can also look at the value in the patch, and check if it's an object/element node or whatever, and replace that value before we actually call applyOperation.
Then in the returned result, take a look at removed for any objects we may need to cleanup/close.

Comment on lines +80 to +81
const reactDomRef: any = useRef(null); // eslint-disable-line @typescript-eslint/no-explicit-any
const reactDomNewRef: any = useRef(null); // eslint-disable-line @typescript-eslint/no-explicit-any
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need to use any here - may need to add typing to the parseDocumentFromObject and use that typing for these.

@wusteven815
Copy link
Contributor Author

Here's the example I use to test around delta updates:

from deephaven import ui,empty_table
from random import randint
from time import time

counter = 0
_tables = []
for i in range(10):
    _tables.append(empty_table(i).update(f"X{i}=i"))

@ui.component
def delta_test():
    global counter
    items, set_items = ui.use_state([])
    i1, seti1 = ui.use_state(0)
    i2, seti2 = ui.use_state(0)
    colour, set_colour = ui.use_state([255, 255, 255])

    def handle_add():
        global counter
        num = counter
        set_items([*items, ui.button(f"{num}", on_press=lambda: print(num)), ui.table(_tables[num], width="80px")])
        counter += 1

    def handle_swap():
        items[i1], items[i2] = items[i2], items[i1]
        set_items([*items])

    return [
        ui.flex(
            ui.button("Add", on_press=handle_add),
            ui.button("Delete", on_press=lambda: set_items([*items[:-1]])),
            ui.button("Swap", on_press=handle_swap),
            ui.text_field(value=i1, on_change=lambda x: seti1(int(x)), width="80px"),
            ui.text_field(value=i2, on_change=lambda x: seti2(int(x)), width="80px"),
            ui.button("Colour", on_press=lambda: set_colour([randint(0, 255), randint(0, 255), randint(0, 255)])),
            ui.view(width="80px", background_color=f"rgb({colour[0]}, {colour[1]}, {colour[2]})",  key="test"),
            max_height="32px"
        ),
        ui.flex(*items, direction="row", flex_grow=1),
    ]

my_delta = delta_test()

Copy link
Collaborator

@mattrunyon mattrunyon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't go too in depth on the logic as I assume some of this is going to change in a potentially significant way with a refactor

encoder_result = self._encoder.encode_node(root)
encoded_document = encoder_result["encoded_node"]
new_objects = encoder_result["new_objects"]
callable_id_dict = encoder_result["callable_id_dict"]

document = json.loads(encoded_document)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should see if we can do this before the node is stringified. Or is this necessary? Just don't want an unnecessary stringify -> parse -> stringify on the server

import WidgetHandler, { WidgetHandlerProps } from './WidgetHandler';
import { DocumentHandlerProps } from './DocumentHandler';
import {
makeWidget,
makeWidgetDescriptor,
makeWidgetEventDocumentUpdated,
makeWidgetEventDocumentPatched,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idk if it's worth changing the name of this event on client/server from update to patch. Update seems fine still. Just reduces the size of the PR since there is no separate update action. Update might even be a clearer name to describe what is happening since patched could imply "fixed"

@mofojed thoughts? I know it's a bit pedantic and I'm fine leaving it as patch. Just thought I'd mention it

@@ -210,7 +213,8 @@ it('updates the initial data only when widget has changed', async () => {
expect(sendMessage).not.toHaveBeenCalled();

const widget2 = makeWidgetDescriptor();
const document2 = { FOO: 'BAR' };
const document2 = { foo: 'bar', FOO: 'BAR' };
const patch2: Operation[] = [{ op: 'add', path: '/FOO', value: 'BAR' }];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should test other operations too. I'm assuming there's remove and modify/change at least

Comment on lines +79 to +81
const uiDomRef = useRef({});
const reactDomRef: any = useRef(null); // eslint-disable-line @typescript-eslint/no-explicit-any
const reactDomNewRef: any = useRef(null); // eslint-disable-line @typescript-eslint/no-explicit-any
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add comments what each of these are supposed to hold (if they stay after the suggested refactor to parse the patch instead of apply then parse special handling).

Also, minor thing, but in a render cycle I prefer to use old or prev or next instead of new. If it's tracking the previous and current state (or current and next for some less common reason).

With the current setup, these might make more sense as reactJSONRef and reactDomRef or something for the object and the parsed object

Comment on lines +191 to +203
const deepCopyAndParse = (obj: unknown, map = new WeakMap()): unknown => {
// make a deep copy of the object and recurse on children before making any replacements
if (obj === null || typeof obj !== 'object') return obj;
if (map.has(obj)) return map.get(obj);
const clone = Array.isArray(obj)
? []
: Object.create(Object.getPrototypeOf(obj));
map.set(obj, clone);
const keys = Reflect.ownKeys(obj);
keys.forEach(key => {
const value = obj[key as keyof typeof obj];
clone[key] = deepCopyAndParse(value, map);
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guessing this will go away in refactor, but you can use something like lodash cloneDeepWith here instead. You can pass it an override function and if you don't return a value, it will handle cloning. So something like cloneDeepWith(obj, val => map.has(val) ? map.get(val) : undefined) I think would do it without tracking the found objects

Comment on lines +284 to +287
oldObj: any, // eslint-disable-line @typescript-eslint/no-explicit-any
newObj: any // eslint-disable-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): { changed: boolean; obj: any } => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer unknown here if we don't know what it is

Comment on lines +355 to +358
if (changed) return { changed: true, obj: { ...obj } };
return { changed: false, obj: oldObj };
},
[]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A callback with no dependencies should just be defined outside of the component. Also lets you break up the function a bit more into smaller, easily testable sections

@mofojed mofojed self-assigned this Dec 31, 2024
@bmingles bmingles removed their request for review January 2, 2025 14:50
@mofojed mofojed mentioned this pull request Jan 22, 2025
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.

Handle document delta updates sent from the server
3 participants