Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
aa155a6
start
ottomated Oct 21, 2025
3b67fe7
pass in form_dat
ottomated Oct 21, 2025
75005a7
serialization
ottomated Oct 21, 2025
7c60494
start deserializer
ottomated Oct 21, 2025
89d7ce2
Merge branch 'main' into streaming-file-forms
ottomated Oct 21, 2025
8a62a3c
finished? deserializer
ottomated Oct 22, 2025
28d6e90
upload progress via XHR
ottomated Oct 22, 2025
ed58a94
simplify file offsets, sort small files first
ottomated Oct 22, 2025
e604470
don't cache stream
ottomated Oct 22, 2025
ecda6ea
fix scoped ids
ottomated Oct 22, 2025
238dd9a
tests
ottomated Oct 22, 2025
5d2c8a5
re-add comment
ottomated Oct 22, 2025
cd106a2
move location & pathname back to headers
ottomated Oct 22, 2025
b4d41f7
skip test on node 18
ottomated Oct 22, 2025
2284b9f
changeset
ottomated Oct 22, 2025
b903988
Merge branch 'main' into streaming-file-forms
ottomated Oct 22, 2025
bcd016b
polyfill file for node 18 test
ottomated Oct 23, 2025
d6e684d
fix refreshes
ottomated Oct 23, 2025
c31ff7c
optimize file offset table
ottomated Oct 23, 2025
9e4853c
typo
ottomated Oct 23, 2025
86ec52a
add lazyfile tests
ottomated Oct 23, 2025
7cb1fcd
Merge branch 'main' into streaming-file-forms
ottomated Oct 25, 2025
1f45e54
Merge branch 'main' into streaming-file-forms
ottomated Nov 1, 2025
aea26e0
avoid double-sending form keys
ottomated Nov 1, 2025
ca9c53c
remove xhr for next PR
ottomated Nov 2, 2025
d78d00b
Merge branch 'main' into streaming-file-forms
ottomated Nov 2, 2025
0c1157c
initial upload progress
ottomated Nov 2, 2025
eae94ee
fix requests stalling if files aren't read
ottomated Nov 2, 2025
921dbc0
Merge branch 'streaming-file-forms' into upload-progress
ottomated Nov 2, 2025
7745038
add test
ottomated Nov 2, 2025
2a08865
changeset
ottomated Nov 2, 2025
7df0c6b
Update .changeset/eager-news-serve.md
ottomated Nov 10, 2025
f13f9a2
Merge branch 'main' into upload-progress
ottomated Nov 10, 2025
bf29572
Update new-rivers-run.md
ottomated Nov 10, 2025
ccddd20
Merge branch 'main' into streaming-file-forms
ottomated Nov 10, 2025
6a78858
Merge branch 'streaming-file-forms' into upload-progress
ottomated Nov 10, 2025
b7eab16
Merge branch 'main' into upload-progress
ottomated Nov 20, 2025
6a67283
Merge branch 'main' into upload-progress
ottomated Nov 20, 2025
5215e8a
expose progress as {uploaded, total}
ottomated Nov 20, 2025
a616d37
Merge branch 'upload-progress' of https://github.com/ottomated/svelte…
ottomated Nov 20, 2025
fdabb68
remove logs
ottomated Nov 20, 2025
2e51482
clean up create_field_proxy accessors
ottomated Nov 20, 2025
6e9060d
fix types & add type tests
ottomated Nov 20, 2025
b03a465
types
ottomated Nov 20, 2025
eee4993
Merge branch 'main' into upload-progress
ottomated Nov 28, 2025
4f9e53d
Merge branch 'main' into upload-progress
ottomated Jan 31, 2026
e9b68c8
move test
ottomated Jan 31, 2026
3b8cfc7
fix tests
ottomated Jan 31, 2026
07e61be
add percent
ottomated Feb 3, 2026
bfecfbf
fix test
ottomated Feb 3, 2026
7da5a19
Merge branch 'main' into upload-progress
ottomated Feb 3, 2026
7d9507f
Merge branch 'main' into upload-progress
teemingc Feb 17, 2026
f9f103f
merge main
Rich-Harris Apr 5, 2026
366edbc
oops
Rich-Harris Apr 5, 2026
84cd705
Merge branch 'main' into upload-progress
ottomated Apr 7, 2026
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/eager-news-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: file upload progress is available via `myForm.fields.someFile.progress()`
14 changes: 13 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1930,7 +1930,19 @@ type RemoteFormFieldMethods<T> = {
set(input: DeepPartial<T>): DeepPartial<T>;
/** Validation issues, if any */
issues(): RemoteFormIssue[] | undefined;
};
} & (T extends File
? {
/** Current file upload progress */
progress(): {
/** Percentage of upload progress, from 0.0 to 1.0 */
readonly percent: number;
/** Bytes uploaded so far */
readonly uploaded: number;
/** Total bytes to upload */
readonly total: number;
};
}
: unknown);

export type RemoteFormFieldValue = string | string[] | number | boolean | File | File[];

Expand Down
47 changes: 27 additions & 20 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,26 +178,33 @@ export function form(validate_or_fn, maybe_fn) {
get() {
return create_field_proxy(
{},
() => get_cache(__)?.['']?.data?.input ?? {},
(path, value) => {
const cache = get_cache(__);
const entry = cache[''];

if (entry?.data?.submission) {
// don't override a submission
return;
}

if (path.length === 0) {
(cache[''] ??= { serialize: true, data: {} }).data.input = value;
return;
}

const input = entry?.data?.input ?? {};
deep_set(input, path.map(String), value);
(cache[''] ??= { serialize: true, data: {} }).data.input = input;
},
() => flatten_issues(get_cache(__)?.['']?.data?.issues ?? [])
{
get_input: () => get_cache(__)?.['']?.data?.input ?? {},
set_input: (path, value) => {
const cache = get_cache(__);
const entry = cache[''];

if (entry?.data?.submission) {
// don't override a submission
return;
}

if (path.length === 0) {
(cache[''] ??= { serialize: true, data: {} }).data.input = value;
return;
}

const input = entry?.data?.input ?? {};
deep_set(input, path.map(String), value);
(cache[''] ??= { serialize: true, data: {} }).data.input = input;
},
get_issues: () => flatten_issues(get_cache(__)?.['']?.data?.issues ?? []),
get_progress: () => ({
uploaded: 0,
total: 0,
percent: 0 /* upload progress is always 0 on the server */
})
}
);
}
});
Expand Down
122 changes: 90 additions & 32 deletions packages/kit/src/runtime/client/remote-functions/form.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import {
build_path_string,
normalize_issue,
serialize_binary_form,
BINARY_FORM_CONTENT_TYPE
BINARY_FORM_CONTENT_TYPE,
deep_get,
get_file_paths
} from '../../form-utils.js';

/**
Expand Down Expand Up @@ -65,6 +67,11 @@ export function form(id) {
*/
let input = $state({});

/**
* @type {Record<string, { uploaded: number, total: number, percent: number }>}
*/
let upload_progress = $state({});

/** @type {InternalRemoteFormIssue[]} */
let raw_issues = $state.raw([]);

Expand Down Expand Up @@ -172,10 +179,10 @@ export function form(id) {
}

/**
* @param {FormData} data
* @param {FormData} form_data
* @returns {Promise<boolean> & { updates: (...args: any[]) => Promise<boolean> }}
*/
function submit(data) {
function submit(form_data) {
// Store a reference to the current instance and increment the usage count for the duration
// of the request. This ensures that the instance is not deleted in case of an optimistic update
// (e.g. when deleting an item in a list) that fails and wants to surface an error to the user afterwards.
Expand Down Expand Up @@ -203,28 +210,66 @@ export function form(id) {
throw updates_error;
}

const { blob } = serialize_binary_form(convert(data), {
const data = convert(form_data);

const { blob, file_offsets } = serialize_binary_form(data, {
remote_refreshes: Array.from(refreshes ?? [])
});

const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, {
method: 'POST',
headers: {
'Content-Type': BINARY_FORM_CONTENT_TYPE,
// Forms cannot be called during rendering, so it's save to use location here
'x-sveltekit-pathname': location.pathname,
'x-sveltekit-search': location.search
},
body: blob
/** @type {string} */
const response_text = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.addEventListener('readystatechange', () => {
switch (xhr.readyState) {
case 2 /* HEADERS_RECEIVED */:
if (xhr.status !== 200) {
// We only end up here if the server has an internal error
// (which shouldn't happen because we handle errors on the server and always send a 200 response)
reject(new Error('Failed to execute remote function'));
}
break;
case 4 /* DONE */:
if (xhr.status !== 200) {
reject(new Error('Failed to execute remote function'));
break;
}
resolve(xhr.responseText);
break;
}
});
if (file_offsets) {
const file_paths = get_file_paths(data);
for (const [file, path] of file_paths) {
deep_set(upload_progress, path, {
uploaded: 0,
total: file.size,
get percent() {
if (this.total === 0) return 0;
return this.uploaded / this.total;
}
});
}
xhr.upload.addEventListener('progress', (ev) => {
for (const file of file_offsets) {
const total = file.file.size;
let uploaded = ev.loaded - file.start;
if (uploaded <= 0) continue;
if (uploaded > total) uploaded = total;
const path = file_paths.get(file.file);
if (!path) continue;
deep_get(upload_progress, path).uploaded = uploaded;
}
});
}
// Use `action_id_without_key` here because the id is included in the body via `convert(data)`
xhr.open('POST', `${base}/${app_dir}/remote/${action_id_without_key}`);
xhr.setRequestHeader('Content-Type', BINARY_FORM_CONTENT_TYPE);
xhr.setRequestHeader('x-sveltekit-pathname', location.pathname);
xhr.setRequestHeader('x-sveltekit-search', location.search);
xhr.send(blob);
});

if (!response.ok) {
// We only end up here in case of a network error or if the server has an internal error
// (which shouldn't happen because we handle errors on the server and always send a 200 response)
throw new Error('Failed to execute remote function');
}

const form_result = /** @type { RemoteFunctionResponse} */ (await response.json());
const form_result = /** @type { RemoteFunctionResponse} */ (JSON.parse(response_text));

// reset issues in case it's a redirect or error (but issues passed in that case)
raw_issues = [];
Expand Down Expand Up @@ -418,6 +463,14 @@ export function form(id) {

if (file) {
set_nested_value(input, name, file);
set_nested_value(upload_progress, name, {
uploaded: 0,
total: file.size,
get percent() {
if (this.total === 0) return 0;
return this.uploaded / this.total;
}
});
} else {
// Remove the property by setting to undefined and clean up
const path_parts = name.split(/\.|\[|\]/).filter(Boolean);
Expand Down Expand Up @@ -449,6 +502,7 @@ export function form(id) {
await tick();

input = convert_formdata(new FormData(form));
upload_progress = {};
};

form.addEventListener('reset', handle_reset);
Expand Down Expand Up @@ -494,18 +548,22 @@ export function form(id) {
get: () =>
create_field_proxy(
{},
() => input,
(path, value) => {
if (path.length === 0) {
input = value;
} else {
deep_set(input, path.map(String), value);

const key = build_path_string(path);
touched[key] = true;
}
},
() => issues
{
get_input: () => input,
set_input: (path, value) => {
if (path.length === 0) {
input = value;
} else {
deep_set(input, path.map(String), value);

const key = build_path_string(path);
touched[key] = true;
}
},
get_issues: () => issues,
get_progress: (path) =>
deep_get(upload_progress, path) ?? { uploaded: 0, total: 0, percent: 0 }
}
)
},
result: {
Expand Down
Loading
Loading