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

Better support for stores with immutable objects #6678

Closed
derolf opened this issue Aug 24, 2021 · 5 comments
Closed

Better support for stores with immutable objects #6678

derolf opened this issue Aug 24, 2021 · 5 comments

Comments

@derolf
Copy link

derolf commented Aug 24, 2021

Describe the problem

$store.age = 33

is desugared into

set_store_value(store, $store.age = 33, $store);

However, if the store is storing an immutable object, it breaks the immutability.

Describe the proposed solution

Use store.update:

store.update(($store) => { 
  $store.age = 33;
  return $store;
}); 

Now, the store can use custom logic to track the changes and/or provide a proxy for updating.

use-case: use immerjs’ produce inside a custom store update to clone objects when modifying them.

Alternatives considered

None

Importance

would make my life easier

@Conduitry
Copy link
Member

.update isn't part of the store contract. It may be present on the writable stores created by svelte/store, but it's not guaranteed to be on other user-written stores.

Additionally, making $foo.bar = 42 desugar to something other than a mutation seems confusing to me. foo.bar = 42 means that mutation is happening, so I don't think $foo.bar = 42 should be any different. In any case, with the built-in stores, the behavior either way would be the same, because .update is passed the original store value, so you'd be mutating that anyway.

We don't want to add .update to the store contract, which would be a breaking change. If you need this behavior, and if your store has implemented a method that handles the cloning in some way, I would say just use that method.

@derolf
Copy link
Author

derolf commented Aug 25, 2021

By using ‘update’ myself, I loose the ability to pass member-expressions as binding values to sub components.

would it be possible to make it somehow configurable? Otherwise, the “immutable” compiler option only goes half way…

@bluwy
Copy link
Member

bluwy commented Aug 25, 2021

@derolf Given update isn't in the store contract. When immutable: false, Svelte generates set_store_value(store, $store.age = 33, $store);; When immutable: true, what should Svelte generate?

I don't think a strategy of "try .update if exist, else fallback to .set" would be reliable too. That could result in non-deterministic behaviour.

@derolf
Copy link
Author

derolf commented Aug 25, 2021

Actually, I found a way to provide two-way bindings for stores that hold immutable using a "derived" store that offers an immerjs Draft for manipulation.

/**
 * Provide two-way bindings to an immutable value.
 */
export function draft<T>(store: Writable<T>): Writable<Draft<T>> {
  const d = writable<Draft<T>>();
  let lastValue: T;
  store.subscribe((value) => {
    lastValue = value;
    d.set(createDraft(value));
  });
  d.subscribe((draft) => {
    // using createDraft(draft) avoids making draft a dead proxy
    const value = finishDraft(createDraft(draft)) as T;
    if (value !== lastValue) {
      store.set(value);
    }
  });

  return d;
}

Usage:

<script>
  const person = writable(
    freeze(
      {
        age: 32,
      },
      true,
    ),
  );

  const personDraft = draft(person);
</script>

<input type="number" bind:value={$personDraft.age} />

@derolf
Copy link
Author

derolf commented Nov 15, 2021

Check out the tool mutable below.

It allows to implicitly apply immerjs produce to do copy-on-write.

Declare a mutable store:

const state = writeable(freeze({foo: "bar}));
const mutState = mutable(state);

Example:

$mutState.$ = 'bar';

is identical to

$state = produce($state, (value) => value.foo = "bar");

The actual use-case is to use directly bind to an input value. Will apply produce instead of direct modification:

<input bind:value={$mutState.foo.$}/>

Code:

import produce from "immer";
import { derived, get } from "svelte/store";
import type { Writable } from "svelte/store";

export type Mutator<T> = { $: T } & { [P in keyof T]-?: Mutator<T[P]> };

/**
 * Turns a store of a deeply nested object into a mutable tree.
 *
 * Assigning to `$` will use `produce` to create a mutated copy of the current value of `store` and assigns it to the `store`
 *
 * Example:
 *
 * ```
 * mutable(people)["foo"].details.age.$ = 99;
 * ```
 *
 * Identical to:
 *
 * ```
 * people.update((people) => produce(people, (value) => value.details.age = 99));
 * ```
 *
 * @param store
 * @returns
 */
export function mutable<T>(store: Writable<T>): Writable<Mutator<T>> {
  const ctx = new WeakMap();
  const mut = derived(store, (value) => _mutable(ctx, store, value, []));
  return {
    subscribe: mut.subscribe,
    set: (newValue) => {
      if (get(mut) !== newValue) {
        throw new Error("Should not be used");
      }
    },
    update: () => {
      throw new Error("Should not be used");
    },
  };
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function _mutable<T>(ctx: WeakMap<any, Mutator<any>>, store: Writable<T>, value: unknown, path: string[]): Mutator<T> {
  if (typeof value === "object" && value !== null) {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    const mut = ctx.get(value);
    if (mut) {
      return mut as Mutator<T>;
    }
  }
  const mut = new Proxy(
    {},
    {
      get: (target, key: string) => {
        if (key !== "$") {
          // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
          return _mutable(ctx, store, (value as any)[key], [...path, key]);
        }
        LOG.info(path.join("."));
        return value;
      },
      set: (target, key: string, newValue) => {
        if (key !== "$") {
          return false;
        }
        if (path.length === 0) {
          store.set(newValue);
          return true;
        }
        store.update((value) =>
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          produce(value, (draft: Record<string, any>) => {
            for (let i = 0; i < path.length - 1; i++) {
              // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
              draft = draft[path[i]];
            }
            if (newValue === undefined) {
              delete draft[path[path.length - 1]];
            } else {
              // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
              draft[path[path.length - 1]] = newValue;
            }
          }),
        );
        return true;
      },
    },
  ) as Mutator<T>;

  if (typeof value === "object" && value !== null) {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    ctx.set(value, mut);
  }
  return mut;
}

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

No branches or pull requests

3 participants