Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/tall-frogs-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte-object": patch
---

fix: overriding parent values breaks reactivity
34 changes: 16 additions & 18 deletions svelte-object/src/SvelteArray.svelte
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
<script lang='ts'>
<script lang='ts' module>
import type { ObjectProps } from './index.svelte'
import Object from './SvelteObject.svelte'
import { type Snippet } from 'svelte'

type T = $$Generic<unknown[]>
type TItem = T extends Array<infer F> ? F : never

interface Props extends ObjectProps<T> {
item?: Snippet<[{
value: TItem
export interface ArrayProps<T extends unknown[]> extends ObjectProps<T> {
item?: Snippet<[props: {
value: T[number]
index: Readonly<number>
attributes: Record<PropertyKey, unknown>
}]>
}
</script>

<script lang='ts'>

import Object from './SvelteObject.svelte'
import { type Snippet } from 'svelte'

type T = $$Generic<unknown[]>

let objectComponent: Object<T>

let v = $state([] as unknown[]) as T
let {
default: defaultvalue,
value = $bindable(),

name = '',
Expand All @@ -26,13 +29,7 @@
modified = $bindable(false),
attributes = $bindable({}),
...rest
}: Props = $props()

v = value!
v ??= [] as unknown[] as T

// svelte-ignore state_referenced_locally
value = v
}: ArrayProps<T> = $props()

function push(...v: T[number][]) {
return value?.push(...v)
Expand All @@ -55,6 +52,7 @@
<Object
bind:this={objectComponent}
{name}
default={defaultvalue as T ?? []}
bind:value={value as T}
bind:modified
bind:attributes
Expand All @@ -69,7 +67,7 @@
{#if item && Array.isArray(value)}
{#each value as valueItem, i (typeof valueItem === 'object' ? valueItem : i)}
{@render item?.({
get value() { return valueItem as TItem },
get value() { return valueItem as T[number] },
set value(newValue) { value![i] = newValue },
get index() { return i },
get attributes() { return attributes },
Expand Down
90 changes: 54 additions & 36 deletions svelte-object/src/SvelteObject.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script lang='ts' module>

export interface Props<T extends Record<PropertyKey, unknown> | unknown[]> {
children?: Snippet<[{
children?: Snippet<[props: {
value: T
attributes: Record<PropertyKey, unknown>
}]>
name?: string | number
default?: T
value?: T

/** What the unmodified object is */
Expand All @@ -18,9 +19,15 @@
onSubmit?: (value: T) => void
}

type Prescriptor = { name: PropertyKey, get: () => unknown, set: (value: unknown) => void }

type SvelteObjectGeneric = Record<PropertyKey, unknown> | unknown[]
type SvelteObjectContext<T extends SvelteObjectGeneric = SvelteObjectGeneric> = {
setValue(key: string | number, newValue: unknown): void
addPrescriptor: (
name: Prescriptor['name'],
getter: Prescriptor['get'],
setter: Prescriptor['set']
) => void
addValidator(fn: (trigger?: ValidationType) => boolean): void
removeValidator(fn: (trigger?: ValidationType) => boolean): void
submit: () => void
Expand All @@ -42,12 +49,11 @@

type T = $$Generic<SvelteObjectGeneric>

let v = $state({}) as T | undefined

let {
children: slot,

name = '',
default: defaultvalue,
value = $bindable(),

origin,
Expand All @@ -58,18 +64,16 @@
onSubmit
}: Props<T> = $props()

v = value
v ??= {} as T

// svelte-ignore state_referenced_locally
value = v
$effect.pre(() => {
value ??= defaultvalue ?? {} as T
})

let object = getContext('svelte-object') as typeof self
let parent = getContext('svelte-object') as typeof self

function createAttributeProxy(): { value: Record<PropertyKey, unknown> } {
/* eslint-disable-next-line svelte/prefer-writable-derived */
let target = $state({
...object?.attributes,
...parent?.attributes,
...attributes
})

Expand All @@ -83,7 +87,7 @@

$effect.pre(() => {
target = {
...object?.attributes,
...parent?.attributes,
...attributes
}
})
Expand All @@ -98,15 +102,41 @@

let validators = [] as (typeof validate)[]

const prescriptors: Prescriptor[] = $state([])

$effect.pre(() => {
for(const prescriptor of prescriptors) {
$effect.pre(() => {
prescriptor.set(value?.[prescriptor.name])
})
$effect.pre(() => {
const itemValue = prescriptor.get()
value ??= {} as T
prescriptor.name
untrack(() => {
value ??= {} as T
value[prescriptor.name] = itemValue
})
})
}
})
Comment on lines +108 to +122

Copilot AI Jul 3, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] Registering nested $effect.pre callbacks inside a loop on every state change may degrade performance and lead to redundant effect registrations. Consider moving these registrations outside the loop or consolidating the reactive updates.

Suggested change
for(const prescriptor of prescriptors) {
$effect.pre(() => {
prescriptor.set(value?.[prescriptor.name])
})
$effect.pre(() => {
const itemValue = prescriptor.get()
value ??= {} as T
prescriptor.name
untrack(() => {
value ??= {} as T
value[prescriptor.name] = itemValue
})
})
}
})
value ??= {} as T;
for (const prescriptor of prescriptors) {
const itemValue = prescriptor.get();
prescriptor.set(value?.[prescriptor.name]);
untrack(() => {
value[prescriptor.name] = itemValue;
});
}
});

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Would break reactivity and the efficiency of such.


const self = {
setValue(key: string | number, newValue: unknown) {
if(key === undefined || key === null || key === '')
return
value![key as keyof T] = newValue as T[keyof T]
if(object && name !== '')
object.setValue(name, value)
addPrescriptor(
name: PropertyKey,
getter: () => unknown,
setter: (value: unknown) => void,
) {
const prescriptor = { name, get: getter, set: setter }

value ??= {} as T
value[prescriptor.name] = getter()

prescriptors.push(prescriptor)
return () => {
prescriptors.splice(prescriptors.indexOf(prescriptor), 1)
}
},

addValidator(fn: typeof validate) {
validators.push(fn)
},
Expand All @@ -131,16 +161,11 @@
}
}
else
object?.submit()
parent?.submit()
}

if(object && (name !== undefined && name !== null) && name !== '') {
if(object?.value?.[name]) {
value = object?.value[name] as T
}
else {
object.value![name] = value
}
if(parent && (name !== undefined && name !== null) && name !== '') {
parent.addPrescriptor(name, () => value, v => value = v as T)
Comment on lines +167 to +168

Copilot AI Jul 3, 2025

Copy link

Choose a reason for hiding this comment

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

The call to parent.addPrescriptor(...) returns an unsubscribe function which isn’t stored or called on component destroy, potentially causing lingering prescriptors. Consider capturing the return value and calling it in onDestroy.

Suggested change
if(parent && (name !== undefined && name !== null) && name !== '') {
parent.addPrescriptor(name, () => value, v => value = v as T)
let unsubscribePrescriptor: (() => void) | undefined;
if(parent && (name !== undefined && name !== null) && name !== '') {
unsubscribePrescriptor = parent.addPrescriptor(name, () => value, v => value = v as T);

Copilot uses AI. Check for mistakes.
}

let checkingIsModified = false
Expand All @@ -155,13 +180,6 @@
}, 125)
}

const setValue = (v: T) => Object.assign(value!, v)
$effect.pre(() => {
if(object?.value) {
setValue(object.value[name] as T)
}
})

$effect(() => {
$state.snapshot(value)
$state.snapshot(origin)
Expand All @@ -181,8 +199,8 @@
return valid
}

object?.addValidator?.(validate)
onDestroy(() => object?.removeValidator?.(validate))
parent?.addValidator?.(validate)
onDestroy(() => parent?.removeValidator?.(validate))

</script>

Expand Down
15 changes: 2 additions & 13 deletions svelte-object/src/SvelteValue.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
onValidate
}: Props<T> & {
onValidate?: (validationEvent: ValidationEvent<T>) => void
children?: Snippet<[{
children?: Snippet<[props: {
value: Props<T>['value']
blurValidation: (element: HTMLElement) => { destroy: () => void }
submitOnEnter: (element: HTMLElement) => { destroy: () => void }
Expand Down Expand Up @@ -122,20 +122,10 @@
object?.addValidator(validate)
onDestroy(() => object?.removeValidator(validate))

const setValue = (v: T) => value = v

if(object && (name !== undefined && name !== null) && name !== '') {
const val = object.value![name!]
if(val !== undefined)
setValue(val)
object.addPrescriptor(name, () => value, v => value = v as T)
}

$effect(() => {
if(object?.value && name !== '') {
setValue(object.value[name!])
}
})

$effect.pre(() => {
value

Expand All @@ -146,7 +136,6 @@
warning = undefined
validate('change')
})
untrack(() => object?.setValue(name, value))
})

</script>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script module>
import 'tailwindcss'
import { defineMeta } from '@storybook/addon-svelte-csf'

import I from '../../../src/index.svelte'

const { Story } = defineMeta({})

let value = $state({})

</script>

<Story name='Reproduction'>
{#snippet template()}
<div class='p-4 border border-gray-300 rounded max-w-72 flex flex-col gap-4'>
<I.Object bind:value>
{#snippet children(object)}
<I.Value name='value'>
{#snippet children(value)}
<input class='border rounded px-4 py-2' bind:value={value.value} />
{/snippet}
</I.Value>
<I.Array name='array' value={[ 'value' ]}>
{#snippet children(array)}
{#each array.value as item, i (i)}
<input class='border rounded px-4 py-2' bind:value={array.value[i]} />
{/each}
<button
class='border px-2 py-2 bg-sky-500 hover:bg-sky-600 rounded text-white cursor-pointer'
onclick={() => array.value.push('')}
>
Add
</button>
{/snippet}
</I.Array>
<pre class='whitespace-pre-wrap'><code>{JSON.stringify(object.value, null, '\t')}</code></pre>
{/snippet}

</I.Object>
</div>
<button
class='border px-2 py-2 bg-gray-500 hover:bg-gray-600 w-72 mt-4 rounded text-white cursor-pointer'
onclick={() => value = { }}
>
Override value
</button>
{/snippet}
</Story>
1 change: 1 addition & 0 deletions svelte-object/stories/issues/overriding-states/README.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When overriding the top-object value reactants such as lower-level arrays and objects stop working.

Copilot AI Jul 3, 2025

Copy link

Choose a reason for hiding this comment

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

Typo in documentation: “reactants” should be “reactivity” or “reactive components” to match the issue description.

Suggested change
When overriding the top-object value reactants such as lower-level arrays and objects stop working.
When overriding the top-object value reactivity such as lower-level arrays and objects stop working.

Copilot uses AI. Check for mistakes.
1 change: 1 addition & 0 deletions svelte-object/stories/tw.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import 'tailwindcss';