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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.2] - 2026-08-20

### Fixed

- **Reactive proxy could be used to pollute `Object.prototype`** — reading, writing, or deleting `__proto__`, `constructor`, or `prototype` through `data` behaved like any other property, so `data.__proto__ = { isAdmin: true }` (or an unguarded `Object.assign(data, JSON.parse(untrustedInput))`) could reach the shared `Object.prototype` and affect every object in the app. These keys are now blocked at the proxy itself, matching the guard already used for path-based updates and merges.

### Demo

- Widened a couple of validators and reactive stores shown in the demo pages, and refreshed the built demo site.

## [2.0.1] - 2026-08-20

### Changed
Expand Down
2 changes: 1 addition & 1 deletion demo/src/pages/ActionDemo.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
createSvState(sourceData, {
validator: (source) => ({
title: stringValidator(source.title).prepare('trim').required().minLength(3).maxLength(50).getError(),
description: stringValidator(source.description).prepare('trim').required().minLength(10).getError()
description: stringValidator(source.description).prepare('trim').required().minLength(10).maxLength(200).getError()
}),
action: async () => {
// Simulate API call with 100-1000ms delay
Expand Down
4 changes: 2 additions & 2 deletions demo/src/pages/ArrayProperty.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ type ItemErrors = Record<string, { name?: string; email?: string }>;

{#each data.items as item, index}
<input bind:value={item.name} />
<ErrorText error={($errors as ItemErrors)?.[...\`item_\${index}\`]?.name ?? ''} />
<ErrorText error={($errors as ItemErrors)?.[\`item_\${index}\`]?.name ?? ''} />

<input bind:value={item.email} />
<ErrorText error={($errors as ItemErrors)?.[...\`item_\${index}\`]?.email ?? ''} />
<ErrorText error={($errors as ItemErrors)?.[\`item_\${index}\`]?.email ?? ''} />
{/each}`;
</script>

Expand Down
8 changes: 4 additions & 4 deletions demo/src/pages/AsyncValidation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,12 @@
// ─────────────────────────────────────────────
// Source code examples for the collapsible section
// ─────────────────────────────────────────────
const stateSourceCode = `const { data, batch, state: { errors, asyncErrors, asyncValidating, hasCombinedErrors } } =
const stateSourceCode = `const { data, batch, state: { errors, hasErrors, isDirty, asyncErrors, asyncValidating, hasCombinedErrors } } =
createSvState(sourceData, {
validator: (source) => ({
username: stringValidator(source.username).required().minLength(3).noSpace().getError(),
email: stringValidator(source.email).required().email().getError(),
slug: stringValidator(source.slug).required().minLength(2).slug().getError()
username: stringValidator(source.username).prepare('trim').required().minLength(3).maxLength(20).noSpace().getError(),
email: stringValidator(source.email).prepare('trim').required().email().getError(),
slug: stringValidator(source.slug).prepare('trim').required().minLength(2).slug().getError()
}),
asyncValidator: {
username: async (value, source, signal) => {
Expand Down
2 changes: 1 addition & 1 deletion demo/src/pages/OptionsDemo.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
// ─────────────────────────────────────────────
const optionsSourceCode = `const { data, execute, state } = createSvState(
sourceData,
{ validator, effect, action },
{ validator, effect, action, actionCompleted },
{
// Reset isDirty to false after successful action
resetDirtyOnAction: true, // default: true
Expand Down
1 change: 1 addition & 0 deletions demo/src/pages/PluginPersistSync.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ batch((draft) => {
draft.username = 'demo_user';
draft.theme = 'dark';
draft.fontSize = 16;
draft.notifications = false;
});`;

const apiSourceCode = `// persistPlugin API
Expand Down
2 changes: 1 addition & 1 deletion demo/src/pages/ZodValidation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ const userSchema = z.object({
);
}

const { data, batch, state: { errors, hasErrors } } = createSvState(sourceData, {
const { data, batch, state: { errors, hasErrors, isDirty, isDirtyByField } } = createSvState(sourceData, {
validator: (source) => zodToSvstateErrors(userSchema, source, allFields)
});

Expand Down
23 changes: 12 additions & 11 deletions docs/assets/index-DofWQVW3.js → docs/assets/index-VyZcMzTz.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<link rel="icon" href="favicon.png" />
<title>svstate demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script type="module" crossorigin src="/svstate/assets/index-DofWQVW3.js"></script>
<script type="module" crossorigin src="/svstate/assets/index-VyZcMzTz.js"></script>
<link rel="stylesheet" crossorigin href="/svstate/assets/index-PZgML0IF.css">
</head>

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "svstate",
"version": "2.0.1",
"version": "2.0.2",
"description": "Supercharged $state() for Svelte 5: deep reactive proxy with validation, cross-field rules, computed & side-effects",
"author": "BCsabaEngine",
"license": "ISC",
Expand Down
11 changes: 11 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DANGEROUS_KEYS } from './internal/paths';

export type ProxyChanged<T extends object> = (
target: T,
property: string,
Expand Down Expand Up @@ -58,6 +60,10 @@ export const ChangeProxy = <T extends object>(source: T, changed: ProxyChanged<T
get(object, property) {
if (property === RAW) return object;
if (typeof property === 'symbol') return (object as Record<symbol, unknown>)[property];
// Never hand back a reactive wrapper around __proto__/constructor/prototype: for
// __proto__ that value is the real, shared Object.prototype, and wrapping it would let
// a write through the returned proxy pollute every object in the process.
if (DANGEROUS_KEYS.has(property)) return (object as Record<string, unknown>)[property];
const value = (object as Record<string, unknown>)[property];
if (isProxiable(value)) return createProxy(value as object, resolvePath(object, property, parentPath));
return value;
Expand All @@ -68,6 +74,10 @@ export const ChangeProxy = <T extends object>(source: T, changed: ProxyChanged<T
(object as Record<symbol, unknown>)[property] = incomingValue;
return true;
}
// Silently reject writes to __proto__/constructor/prototype, same as setValueAtPath and
// safeMerge do, so an untrusted payload (e.g. Object.assign(data, JSON.parse(input)))
// can't repoint the state object's prototype.
if (DANGEROUS_KEYS.has(property)) return true;
// Storing a proxy inside the raw tree would make later mutations report the path the
// value was read from rather than the one it was written to.
const nextValue = unwrap(incomingValue);
Expand All @@ -81,6 +91,7 @@ export const ChangeProxy = <T extends object>(source: T, changed: ProxyChanged<T

deleteProperty(object, property) {
if (typeof property === 'symbol') return Reflect.deleteProperty(object, property);
if (DANGEROUS_KEYS.has(property)) return true;
if (!Object.hasOwn(object, property)) return true;

const oldValue = (object as Record<string, unknown>)[property];
Expand Down
95 changes: 95 additions & 0 deletions test/async-validation.test.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,48 @@ describe('async validation - cancellation', () => {
});
});

describe('async validation - error handling', () => {
it('should store the error message when the async validator throws', async () => {
const { data, state } = createSvState(
{ username: '' },
{
asyncValidator: {
username: async () => {
throw new Error('Server unreachable');
}
}
},
{ debounceAsyncValidation: 10 }
);

data.username = 'test';
await new Promise((resolve) => setTimeout(resolve, 50));

expect(get(state.asyncErrors)).toEqual({ username: 'Server unreachable' });
expect(get(state.hasAsyncErrors)).toBe(true);
expect(get(state.asyncValidating)).toEqual([]);
});

it('should unwrap a non-Error throw into a message', async () => {
const { data, state } = createSvState(
{ username: '' },
{
asyncValidator: {
username: async () => {
throw 'plain string rejection';
}
}
},
{ debounceAsyncValidation: 10 }
);

data.username = 'test';
await new Promise((resolve) => setTimeout(resolve, 50));

expect(get(state.asyncErrors)).toEqual({ username: 'plain string rejection' });
});
});

describe('async validation - asyncValidating store', () => {
it('should update asyncValidating store during validation', async () => {
const { data, state } = createSvState(
Expand Down Expand Up @@ -561,6 +603,59 @@ describe('async validation - nested paths', () => {
expect(isAsyncValidatorCalled).toBe(true);
expect(get(state.asyncErrors)).toEqual({ user: 'User invalid' });
});

it('should pass undefined to the validator when a path segment resolves through a null ancestor', async () => {
let receivedValue: unknown = 'not called';

const { data } = createSvState(
{ user: { email: 'x@y.z' } as { email: string } | null },
{
asyncValidator: {
'user.email': async (value) => {
receivedValue = value;
return '';
}
}
},
{ debounceAsyncValidation: 10 }
);

// "user" becoming null matches registered "user.email" (parent triggers child), and reading
// through the now-null "user" must not throw
// eslint-disable-next-line unicorn/no-null
data.user = null;
await new Promise((resolve) => setTimeout(resolve, 50));

expect(receivedValue).toBeUndefined();
});

it('should still run when only a descendant path has a sync error, not the registered path itself', async () => {
let isAsyncValidatorCalled = false;

const { data, state } = createSvState(
{ user: { name: 'x' } },
{
validator: (source) => ({
user: { name: source.user.name ? '' : 'Required' }
}),
asyncValidator: {
// "user" itself resolves to a nested object, not a string, so the sync-error check
// (which only looks at the exact registered path) must not block this
user: async () => {
isAsyncValidatorCalled = true;
return '';
}
}
},
{ debounceAsyncValidation: 10 }
);

data.user.name = '';
await new Promise((resolve) => setTimeout(resolve, 50));

expect(get(state.errors)).toEqual({ user: { name: 'Required' } });
expect(isAsyncValidatorCalled).toBe(true);
});
});

describe('async validation - receives full source', () => {
Expand Down
24 changes: 24 additions & 0 deletions test/plugins-analytics.test.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ describe('analyticsPlugin', () => {
expect(flushed.length).toBeGreaterThanOrEqual(1);
});

it('should use the default 5000ms flush interval when not specified', () => {
vi.useFakeTimers();
try {
const flushed: AnalyticsEvent[][] = [];
const analytics = analyticsPlugin({
onFlush: (events) => {
flushed.push([...events]);
},
batchSize: 100
});
const { data, destroy } = createSvState({ name: 'test' }, undefined, { plugins: [analytics] });

data.name = 'updated';
expect(flushed.length).toBe(0);

vi.advanceTimersByTime(5000);
expect(flushed.length).toBe(1);

destroy();
} finally {
vi.useRealTimers();
}
});

it('should filter by include types', () => {
const flushed: AnalyticsEvent[][] = [];
const analytics = analyticsPlugin({
Expand Down
46 changes: 46 additions & 0 deletions test/plugins-autosave.test.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,52 @@ describe('autosavePlugin', () => {
expect(saved.length).toBeGreaterThanOrEqual(2);
});

it('should run a save requested while one is already in flight instead of dropping it', async () => {
const saveCalls: string[] = [];
const { promise: inFlight, resolve: resolveInFlight } = Promise.withResolvers<void>();
const autosave = autosavePlugin({
save: async (data) => {
saveCalls.push((data as { name: string }).name);
if (saveCalls.length === 1) await inFlight;
},
idle: 5000,
interval: 15,
onlyWhenDirty: false,
saveOnDestroy: false
});
const { data, destroy } = createSvState({ name: 'a' }, undefined, { plugins: [autosave] });

// First interval tick starts the save and hangs on the unresolved promise
await new Promise((r) => setTimeout(r, 25));
expect(autosave.isSaving()).toBe(true);

data.name = 'b';
// A later interval tick requests a save while the first is still in flight, and must not be
// dropped even though `isSaving` was true at request time
await new Promise((r) => setTimeout(r, 25));

resolveInFlight();
await new Promise((r) => setTimeout(r, 15));
destroy();

expect(saveCalls[0]).toBe('a');
expect(saveCalls).toContain('b');
expect(autosave.isSaving()).toBe(false);
});

it('should not touch document when onVisibilityHidden is set but document is unavailable', () => {
const autosave = autosavePlugin({
save: () => {},
onVisibilityHidden: true,
saveOnDestroy: false
});

expect(() => {
const { destroy } = createSvState({ name: 'a' }, undefined, { plugins: [autosave] });
destroy();
}).not.toThrow();
});

it('should clear idle timer after successful action', async () => {
const saved: unknown[] = [];
const autosave = autosavePlugin({
Expand Down
Loading