Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 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
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()`
12 changes: 11 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1897,7 +1897,17 @@ type RemoteFormFieldMethods<T> = {
set(input: T): T;
/** Validation issues, if any */
issues(): RemoteFormIssue[] | undefined;
};
} & (T extends File
? {
/** Current file upload progress */
progress(): {
/** 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
32 changes: 19 additions & 13 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,19 +202,25 @@ export function form(validate_or_fn, maybe_fn) {

return create_field_proxy(
{},
() => data?.input ?? {},
(path, value) => {
if (data?.submission) {
// don't override a submission
return;
}

const input =
path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value);

(get_cache(__)[''] ??= {}).input = input;
},
() => issues
{
get_input: () => data?.input ?? {},
set_input: (path, value) => {
if (data?.submission) {
// don't override a submission
return;
}

const input =
path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value);

(get_cache(__)[''] ??= {}).input = input;
},
get_issues: () => issues,
get_progress: () => ({
uploaded: 0,
total: 0
}) /* upload progress is always 0 on the server */
}
);
}
});
Expand Down
106 changes: 75 additions & 31 deletions packages/kit/src/runtime/client/remote-functions/form.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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 @@ -66,6 +68,11 @@ export function form(id) {
*/
let input = $state({});

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

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

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

/**
* @param {FormData} data
* @param {FormData} form_data
* @returns {Promise<any> & { updates: (...args: any[]) => any }}
*/
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 All @@ -185,27 +192,59 @@ export function form(id) {
try {
await Promise.resolve();

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

const { blob, file_offsets } = serialize_binary_form(data, {
remote_refreshes: updates.map((u) => u._key)
});

const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, {
method: 'POST',
headers: {
'Content-Type': BINARY_FORM_CONTENT_TYPE,
'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 });
}
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 @@ -377,6 +416,7 @@ export function form(id) {

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

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

return () => {
Expand Down Expand Up @@ -497,18 +538,21 @@ 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 }
}
)
},
result: {
Expand Down
76 changes: 55 additions & 21 deletions packages/kit/src/runtime/form-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,22 +96,27 @@ export function serialize_binary_form(data, meta) {
}
});

const encoded_header_buffer = text_encoder.encode(encoded_header);
/** @type {Array<number> | undefined} */
let file_offsets;

let encoded_file_offsets = '';

/** @type {Array<[file: File, index: number]>} */
let unsorted_files;
if (files.length) {
unsorted_files = [...files];
// Sort small files to the front
files.sort(([a], [b]) => a.size - b.size);

/** @type {Array<number>} */
const file_offsets = new Array(files.length);
file_offsets = new Array(files.length);
let start = 0;
for (const [file, index] of files) {
file_offsets[index] = start;
start += file.size;
}
encoded_file_offsets = JSON.stringify(file_offsets);
}
const encoded_header_buffer = text_encoder.encode(encoded_header);

const length_buffer = new Uint8Array(4);
const length_view = new DataView(length_buffer.buffer);
Expand All @@ -129,8 +134,14 @@ export function serialize_binary_form(data, meta) {
blob_parts.push(file);
}

const file_offset_start = 1 + 4 + 2 + encoded_header.length + encoded_file_offsets.length;

return {
blob: new Blob(blob_parts)
blob: new Blob(blob_parts),
file_offsets: file_offsets?.map((o, i) => ({
start: o + file_offset_start,
file: unsorted_files[i][0]
}))
};
}

Expand Down Expand Up @@ -426,6 +437,27 @@ function check_prototype_pollution(key) {
}
}

/**
* Finds the paths to every File in an object
* @param {unknown} object
* @param {Map<File, string[]>} paths
* @param {string[]} path
*/
export function get_file_paths(object, paths = new Map(), path = []) {
if (Array.isArray(object)) {
for (let i = 0; i < object.length; i++) {
get_file_paths(object[i], paths, [...path, i.toString()]);
}
} else if (object instanceof File) {
paths.set(object, path);
} else if (typeof object === 'object' && object !== null) {
for (const [key, value] of Object.entries(object)) {
get_file_paths(value, paths, [...path, key]);
}
}
return paths;
}

/**
* Sets a value in a nested object using an array of keys, mutating the original object.
* @param {Record<string, any>} object
Expand Down Expand Up @@ -540,15 +572,14 @@ export function deep_get(object, path) {
/**
* Creates a proxy-based field accessor for form data
* @param {any} target - Function or empty POJO
* @param {() => Record<string, any>} get_input - Function to get current input data
* @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data
* @param {() => Record<string, InternalRemoteFormIssue[]>} get_issues - Function to get current issues
* @param {{ get_input: () => Record<string, any>, set_input: (path: (string | number)[], value: any) => void, get_issues: () => Record<string, InternalRemoteFormIssue[]>, get_progress: (path: (string | number)[]) => { uploaded: number, total: number } }} accessors - Accessor functions
* @param {(string | number)[]} path - Current access path
*
* @returns {any} Proxy object with name(), value(), and issues() methods
*/
export function create_field_proxy(target, get_input, set_input, get_issues, path = []) {
export function create_field_proxy(target, accessors, path = []) {
const get_value = () => {
return deep_get(get_input(), path);
return deep_get(accessors.get_input(), path);
};

return new Proxy(target, {
Expand All @@ -557,29 +588,26 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat

// Handle array access like jobs[0]
if (/^\d+$/.test(prop)) {
return create_field_proxy({}, get_input, set_input, get_issues, [
...path,
parseInt(prop, 10)
]);
return create_field_proxy({}, accessors, [...path, parseInt(prop, 10)]);
}

const key = build_path_string(path);

if (prop === 'set') {
const set_func = function (/** @type {any} */ newValue) {
set_input(path, newValue);
accessors.set_input(path, newValue);
return newValue;
};
return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]);
return create_field_proxy(set_func, accessors, [...path, prop]);
}

if (prop === 'value') {
return create_field_proxy(get_value, get_input, set_input, get_issues, [...path, prop]);
return create_field_proxy(get_value, accessors, [...path, prop]);
}

if (prop === 'issues' || prop === 'allIssues') {
const issues_func = () => {
const all_issues = get_issues()[key === '' ? '$' : key];
const all_issues = accessors.get_issues()[key === '' ? '$' : key];

if (prop === 'allIssues') {
return all_issues?.map((issue) => ({
Expand All @@ -596,7 +624,13 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
}));
};

return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]);
return create_field_proxy(issues_func, accessors, [...path, prop]);
}

if (prop === 'progress') {
const progress_func = () => accessors.get_progress(path);

return create_field_proxy(progress_func, accessors, [...path, prop]);
}

if (prop === 'as') {
Expand All @@ -622,7 +656,7 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
const base_props = {
name: prefix + key + (is_array ? '[]' : ''),
get 'aria-invalid'() {
const issues = get_issues();
const issues = accessors.get_issues();
return key in issues ? 'true' : undefined;
}
};
Expand Down Expand Up @@ -745,11 +779,11 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
});
};

return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']);
return create_field_proxy(as_func, accessors, [...path, 'as']);
}

// Handle property access (nested fields)
return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]);
return create_field_proxy({}, accessors, [...path, prop]);
}
});
}
Expand Down
Loading
Loading