Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
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 on `myForm.fields.someFile.progress()`
5 changes: 5 additions & 0 deletions .changeset/new-rivers-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: File uploads inside `form` remote functions are now streamed - form data can be accessed before large files finish uploading.
7 changes: 6 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1880,7 +1880,12 @@ type RemoteFormFieldMethods<T> = {
set(input: T): T;
/** Validation issues, if any */
issues(): RemoteFormIssue[] | undefined;
};
} & (T extends File
? {
/** Current upload progress, from 0 to 1 */
progress(): number;
}
: object);

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

Expand Down
31 changes: 9 additions & 22 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import { get_request_store } from '@sveltejs/kit/internal/server';
import { DEV } from 'esm-env';
import {
convert_formdata,
create_field_proxy,
set_nested_value,
throw_on_old_property_access,
Expand Down Expand Up @@ -104,19 +103,7 @@ export function form(validate_or_fn, maybe_fn) {
type: 'form',
name: '',
id: '',
/** @param {FormData} form_data */
fn: async (form_data) => {
const validate_only = form_data.get('sveltekit:validate_only') === 'true';

let data = maybe_fn ? convert_formdata(form_data) : undefined;

if (data && data.id === undefined) {
const id = form_data.get('sveltekit:id');
if (typeof id === 'string') {
data.id = JSON.parse(id);
}
}

fn: async (data, meta, form_data) => {
// TODO 3.0 remove this warning
if (DEV && !data) {
const error = () => {
Expand Down Expand Up @@ -152,12 +139,12 @@ export function form(validate_or_fn, maybe_fn) {
const { event, state } = get_request_store();
const validated = await schema?.['~standard'].validate(data);

if (validate_only) {
if (meta.validate_only) {
return validated?.issues?.map((issue) => normalize_issue(issue, true)) ?? [];
}

if (validated?.issues !== undefined) {
handle_issues(output, validated.issues, event.isRemoteRequest, form_data);
handle_issues(output, validated.issues, form_data);
} else {
if (validated !== undefined) {
data = validated.value;
Expand All @@ -178,7 +165,7 @@ export function form(validate_or_fn, maybe_fn) {
);
} catch (e) {
if (e instanceof ValidationError) {
handle_issues(output, e.issues, event.isRemoteRequest, form_data);
handle_issues(output, e.issues, form_data);
} else {
throw e;
}
Expand Down Expand Up @@ -226,7 +213,8 @@ export function form(validate_or_fn, maybe_fn) {

(get_cache(__)[''] ??= {}).input = input;
},
() => issues
() => issues,
() => 0 /* upload progress is always 0 on the server */
);
}
});
Expand Down Expand Up @@ -297,15 +285,14 @@ export function form(validate_or_fn, maybe_fn) {
/**
* @param {{ issues?: InternalRemoteFormIssue[], input?: Record<string, any>, result: any }} output
* @param {readonly StandardSchemaV1.Issue[]} issues
* @param {boolean} is_remote_request
* @param {FormData} form_data
* @param {FormData | null} form_data - null if the form is progressively enhanced
*/
function handle_issues(output, issues, is_remote_request, form_data) {
function handle_issues(output, issues, form_data) {
output.issues = issues.map((issue) => normalize_issue(issue, true));

// if it was a progressively-enhanced submission, we don't need
// to return the input — it's already there
if (!is_remote_request) {
if (form_data) {
output.input = {};

for (let key of form_data.keys()) {
Expand Down
105 changes: 74 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 @@ -18,7 +18,11 @@ import {
set_nested_value,
throw_on_old_property_access,
build_path_string,
normalize_issue
normalize_issue,
serialize_binary_form,
BINARY_FORM_CONTENT_TYPE,
deep_get,
get_file_paths
} from '../../form-utils.js';

/**
Expand Down Expand Up @@ -55,6 +59,7 @@ export function form(id) {

/** @param {string | number | boolean} [key] */
function create_instance(key) {
const action_id_without_key = id;
const action_id = id + (key != undefined ? `/${JSON.stringify(key)}` : '');
const action = '?/remote=' + encodeURIComponent(action_id);

Expand All @@ -63,6 +68,11 @@ export function form(id) {
*/
let input = $state({});

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

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

Expand Down Expand Up @@ -157,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 @@ -182,26 +192,55 @@ export function form(id) {
try {
await Promise.resolve();

if (updates.length > 0) {
data.set('sveltekit:remote_refreshes', JSON.stringify(updates.map((u) => u._key)));
}
const data = convert(form_data);

const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, {
method: 'POST',
body: data,
headers: {
'x-sveltekit-pathname': location.pathname,
'x-sveltekit-search': location.search
}
const { blob, file_offsets } = serialize_binary_form(data, {
remote_refreshes: updates.map((u) => u._key)
});

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');
}
/** @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);
xhr.upload.addEventListener('progress', (ev) => {
for (const file of file_offsets) {
let progress = (ev.loaded - file.start) / file.file.size;
if (progress <= 0) continue;
if (progress > 1) progress = 1;
const path = file_paths.get(file.file);
if (!path) continue;
deep_set(upload_progress, path, progress);
}
});
}
// 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);
});

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

if (form_result.type === 'result') {
({ issues: raw_issues = [], result } = devalue.parse(form_result.result, app.decoders));
Expand Down Expand Up @@ -370,6 +409,7 @@ export function form(id) {

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

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

return () => {
Expand Down Expand Up @@ -501,7 +542,8 @@ export function form(id) {
touched[key] = true;
}
},
() => issues
() => issues,
(path) => deep_get(upload_progress, path) ?? 0
)
},
result: {
Expand Down Expand Up @@ -532,7 +574,9 @@ export function form(id) {
/** @type {InternalRemoteFormIssue[]} */
let array = [];

const validated = await preflight_schema?.['~standard'].validate(convert(form_data));
const data = convert(form_data);

const validated = await preflight_schema?.['~standard'].validate(data);

if (validate_id !== id) {
return;
Expand All @@ -541,11 +585,16 @@ export function form(id) {
if (validated?.issues) {
array = validated.issues.map((issue) => normalize_issue(issue, false));
} else if (!preflightOnly) {
form_data.set('sveltekit:validate_only', 'true');

const response = await fetch(`${base}/${app_dir}/remote/${action_id}`, {
const response = await fetch(`${base}/${app_dir}/remote/${action_id_without_key}`, {
method: 'POST',
body: form_data
headers: {
'Content-Type': BINARY_FORM_CONTENT_TYPE,
'x-sveltekit-pathname': location.pathname,
'x-sveltekit-search': location.search
},
body: serialize_binary_form(data, {
validate_only: true
}).blob
});

const result = await response.json();
Expand Down Expand Up @@ -637,12 +686,6 @@ function clone(element) {
*/
function validate_form_data(form_data, enctype) {
for (const key of form_data.keys()) {
if (key.startsWith('sveltekit:')) {
throw new Error(
'FormData keys starting with `sveltekit:` are reserved for internal use and should not be set manually'
);
}

if (/^\$[.[]?/.test(key)) {
throw new Error(
'`$` is used to collect all FormData validation issues and cannot be used as the `name` of a form control'
Expand Down
Loading
Loading