From 6b494af2ac857d0ce1d6dbb472663ef67d9dc050 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 12:45:46 -0700 Subject: [PATCH 01/10] feat: File Upload Progress --- .changeset/eager-views-enjoy.md | 5 + .../kit/src/runtime/app/server/public.d.ts | 14 ++- .../kit/src/runtime/app/server/remote/form.js | 4 +- .../client/remote-functions/form.svelte.js | 113 +++++++++++++++--- .../client/remote-functions/shared.svelte.js | 8 ++ packages/kit/src/runtime/form-utils.js | 48 +++++++- .../remote/form/file-upload/+page.svelte | 13 +- .../remote/form/file-upload/form.remote.ts | 8 +- packages/kit/test/apps/async/test/test.js | 72 ++++++++++- packages/kit/test/types/remote.test.ts | 15 +++ packages/kit/types/index.d.ts | 14 ++- 11 files changed, 276 insertions(+), 38 deletions(-) create mode 100644 .changeset/eager-views-enjoy.md diff --git a/.changeset/eager-views-enjoy.md b/.changeset/eager-views-enjoy.md new file mode 100644 index 000000000000..4539972de321 --- /dev/null +++ b/.changeset/eager-views-enjoy.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": minor +--- + +file upload progress is available via `myForm.fields.someFile.progress()` diff --git a/packages/kit/src/runtime/app/server/public.d.ts b/packages/kit/src/runtime/app/server/public.d.ts index 8ad4728407b5..1c4ae050cc75 100644 --- a/packages/kit/src/runtime/app/server/public.d.ts +++ b/packages/kit/src/runtime/app/server/public.d.ts @@ -116,7 +116,19 @@ type RemoteFormFieldMethods = { dirty(): boolean; /** 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); // These two types use "T extends unknown ? .. : .." to distribute over unions. // Example: if "type T = A | b" then "keyof T" only contains keys that both A and B have, with "KeysOfUnion" we get the keys of both A and B diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 127df8210f3b..ad80678aa259 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -194,7 +194,9 @@ export function form(validate_or_fn, maybe_fn) { get_issues: () => flatten_issues(get_cache(__, get_request_store().state)?.['']?.issues ?? []), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + // upload progress is always 0 on the server + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); } }); diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index f690e9288e31..5ec42b2ef3a3 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -15,7 +15,12 @@ import { } from '../client.js'; import { page } from '#app/state/client'; import { tick } from 'svelte'; -import { categorize_updates, fail_unhandled_refreshes, remote_request } from './shared.svelte.js'; +import { + categorize_updates, + fail_unhandled_refreshes, + handle_remote_response, + remote_request +} from './shared.svelte.js'; import { createAttachmentKey } from 'svelte/attachments'; import { convert_formdata, @@ -30,7 +35,8 @@ import { DELETE_KEY, BINARY_FORM_CONTENT_TYPE, parse_form_key, - coerce_form_value + coerce_form_value, + get_file_paths } from '../../form-utils.js'; /** @@ -122,6 +128,11 @@ export function form(id) { /** @type {any} */ let result = $state.raw(initial?.result); + /** + * @type {Record} + */ + let upload_progress = $state({}); + /** @type {number} */ let pending_count = $state(0); @@ -238,24 +249,63 @@ export function form(id) { if (!valid) return false; } - const { blob } = serialize_binary_form(convert(form_data), { + const data = convert(form_data); + const { blob, file_offsets } = serialize_binary_form(data, { remote_refreshes: Array.from(refreshes ?? []) }); - const response = await remote_request( - `${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 - }, - refreshes - ); + /** @type {Response} */ + const xhr_response = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.addEventListener('load', () => { + resolve( + new Response(xhr.response ?? xhr.responseText, { + status: xhr.status, + statusText: xhr.statusText, + headers: parse_xhr_headers(xhr.getAllResponseHeaders()) + }) + ); + }); + xhr.addEventListener('error', () => { + reject(new TypeError('Network request failed')); + }); + + 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; + // Could be null if the form was reset mid-upload + const progress = deep_get(upload_progress, path); + if (!progress) continue; + progress.uploaded = uploaded; + } + }); + } + + 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 response = await handle_remote_response(xhr_response, refreshes); ({ issues: raw_issues = [], result } = response._ ?? {}); @@ -536,6 +586,14 @@ export function form(id) { if (file) { set_nested_value(input, field, file); + set_nested_value(upload_progress, field, { + uploaded: 0, + total: file.size, + get percent() { + if (this.total === 0) return 0; + return this.uploaded / this.total; + } + }); } else { set_nested_value(input, field, DELETE_KEY); } @@ -563,6 +621,7 @@ export function form(id) { touched = {}; dirty = {}; can_validate = {}; + upload_progress = {}; submitted = false; }; @@ -662,7 +721,9 @@ export function form(id) { return issues; }, get_touched: () => touched, - get_dirty: () => dirty + get_dirty: () => dirty, + get_progress: (path) => + deep_get(upload_progress, path) ?? { uploaded: 0, total: 0, percent: 0 } }) }, result: { @@ -828,3 +889,19 @@ function validate_form_data(form_data, enctype) { } } } + +/** + * @param {string} xhr_headers + * @returns {Headers} + */ +function parse_xhr_headers(xhr_headers) { + const headers = new Headers(); + const lines = xhr_headers.trim().split('\n'); + for (const line of lines) { + const colon_index = line.indexOf(':'); + const key = line.substring(0, colon_index).trim(); + const value = line.substring(colon_index + 1).trim(); + headers.append(key, value); + } + return headers; +} diff --git a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js index 354da4230d10..0e0b25207f07 100644 --- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js @@ -114,6 +114,14 @@ export function get_remote_request_headers() { */ export async function remote_request(url, init, refreshes) { const response = await fetch(url, init); + return handle_remote_response(response, refreshes); +} + +/** + * @param {Response} response + * @param {Set | null} [refreshes] + */ +export async function handle_remote_response(response, refreshes) { const status = response.status; // detect new deployments from the response header diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index f40a2adbf83a..2a787bb26b8a 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -142,15 +142,20 @@ export function serialize_binary_form(data, meta) { } }); - const encoded_header_buffer = text_encoder.encode(encoded_header); + /** @type {Array | 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} */ - 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; @@ -159,6 +164,8 @@ export function serialize_binary_form(data, meta) { 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); @@ -175,11 +182,38 @@ export function serialize_binary_form(data, meta) { blob_parts.push(file); } + const file_offset_start = HEADER_BYTES + 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] + })) }; } +/** + * Finds the paths to every File in an object + * @param {unknown} object + * @param {Map} 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; +} + /** * @param {Request} request * @param {string} form_id @@ -719,7 +753,8 @@ const public_issue = (issue) => ({ path: issue.path, message: issue.message }); * set: (path: (string | number)[], value: any) => void, * get_issues: (path?: (string | number)[], all?: boolean) => Record, * get_touched: () => Record, - * get_dirty: () => Record + * get_dirty: () => Record, + * get_progress: (path: (string | number)[]) => { uploaded: number, total: number, percent: number } * }} FieldContext */ /** @@ -766,6 +801,9 @@ function create_field_method(context, path, prop) { return false; }; } + case 'progress': { + return () => context.get_progress(path); + } case 'as': { const key = build_path_string(path); diff --git a/packages/kit/test/apps/async/src/routes/remote/form/file-upload/+page.svelte b/packages/kit/test/apps/async/src/routes/remote/form/file-upload/+page.svelte index 850fa70a14db..50ff4c8439c9 100644 --- a/packages/kit/test/apps/async/src/routes/remote/form/file-upload/+page.svelte +++ b/packages/kit/test/apps/async/src/routes/remote/form/file-upload/+page.svelte @@ -4,17 +4,22 @@
-

File 1:

+

+ File 1: (progress: {JSON.stringify(upload.fields.file1.progress())}) +

-

File 2:

- +

File 2: (progress: {JSON.stringify(upload.fields.deep.files[0].progress())})

+ +

File 3: (progress: {JSON.stringify(upload.fields.deep.files[1].progress())})

+

- + +
{JSON.stringify(upload.result)}
diff --git a/packages/kit/test/apps/async/src/routes/remote/form/file-upload/form.remote.ts b/packages/kit/test/apps/async/src/routes/remote/form/file-upload/form.remote.ts index 0282c8ff9264..9c36d1a75a77 100644 --- a/packages/kit/test/apps/async/src/routes/remote/form/file-upload/form.remote.ts +++ b/packages/kit/test/apps/async/src/routes/remote/form/file-upload/form.remote.ts @@ -5,7 +5,9 @@ export const upload = form( v.object({ text: v.string(), file1: v.file(), - file2: v.file(), + deep: v.object({ + files: v.array(v.file()) + }), read_files: v.optional(v.boolean()) }), async (data) => { @@ -13,13 +15,13 @@ export const upload = form( return { text: data.text, file1: data.file1.size, - file2: data.file2.size + files: data.deep.files.map((f) => f.size) }; } return { text: data.text, file1: await data.file1.text(), - file2: await data.file2.text() + files: await Promise.all(data.deep.files.map((f) => f.text())) }; } ); diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index d836acb658b0..c39d6d39061a 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -993,19 +993,24 @@ test.describe('remote functions', () => { mimeType: 'text/plain', buffer: Buffer.from('a') }); - await page.locator('input[name^="file2"]').setInputFiles({ + await page.locator('input[name^="deep.files[0]"]').setInputFiles({ name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') }); + await page.locator('input[name^="deep.files[1]"]').setInputFiles({ + name: 'c.txt', + mimeType: 'text/plain', + buffer: Buffer.from('c') + }); await page.locator('input[type="checkbox"]').check(); - await page.locator('button').click(); + await page.locator('#submit').click(); await expect(page.locator('pre')).toHaveText( JSON.stringify({ text: 'Hello world', file1: 'a', - file2: 'b' + files: ['b', 'c'] }) ); }); @@ -1017,22 +1022,79 @@ test.describe('remote functions', () => { mimeType: 'text/plain', buffer: Buffer.alloc(1024 * 1024 * 10) }); - await page.locator('input[name^="file2"]').setInputFiles({ + await page.locator('input[name^="deep.files[0]"]').setInputFiles({ name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') }); + await page.locator('input[name^="deep.files[1]"]').setInputFiles({ + name: 'cc.txt', + mimeType: 'text/plain', + buffer: Buffer.from('cc') + }); await page.locator('button').click(); await expect(page.locator('pre')).toHaveText( JSON.stringify({ text: 'Hello world', file1: 1024 * 1024 * 10, - file2: 1 + file2: [1, 2] }) ); }); + test('file upload progress works', async ({ page, context, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + await page.goto('/remote/form/file-upload'); + const cdp = await context.newCDPSession(page); + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: 1024 * 1024 * 5 // throttle so it'll take 2 seconds to upload + }); + try { + const progress = page.locator('#progress1'); + expect(progress).toHaveText('{"uploaded":0,"total":0,"percent":0}'); + await page.locator('input[name="file1"]').setInputFiles({ + name: 'a.txt', + mimeType: 'text/plain', + buffer: Buffer.alloc(1024 * 1024 * 10) + }); + expect(progress).toHaveText('{"uploaded":0,"total":10485760,"percent":0}'); + await page.locator('input[name="deep.files[0]"]').setInputFiles({ + name: 'b.txt', + mimeType: 'text/plain', + buffer: Buffer.from('b') + }); + await page.locator('input[name="deep.files[1]"]').setInputFiles({ + name: 'c.txt', + mimeType: 'text/plain', + buffer: Buffer.from('c') + }); + await page.locator('#submit').click(); + await expect(progress).not.toHaveText(/"uploaded":0/); + async function check_percent() { + const progress_value = JSON.parse(/** @type {string} */ (await progress.textContent())); + expect(progress_value.percent).toBeGreaterThan(0); + expect(progress_value.percent).toBeCloseTo(progress_value.uploaded / progress_value.total); + } + const progress_text = /** @type {string} */ (await progress.textContent()); + await check_percent(); + await expect(progress).not.toHaveText(progress_text); + await check_percent(); + await page.locator('#reset').click(); + await expect(progress).toHaveText('{"uploaded":0,"total":0,"percent":0}'); + } finally { + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1 + }); + } + }); + test('query stored as variable does not block SSR inside boundary', async ({ page, javaScriptEnabled diff --git a/packages/kit/test/types/remote.test.ts b/packages/kit/test/types/remote.test.ts index ed8824b0f9a8..91d9ca7c802c 100644 --- a/packages/kit/test/types/remote.test.ts +++ b/packages/kit/test/types/remote.test.ts @@ -780,6 +780,21 @@ function form_tests() { }>, () => {} ); + + // file upload progress + const f13 = form(null as any as StandardSchemaV1<{ file: File; nonfile: number }>, () => {}); + f13.fields.file.progress().uploaded; + f13.fields.file.progress().total; + f13.fields.file.progress().percent; + // readonly: + // @ts-expect-error + f13.fields.file.progress().uploaded = 123; + // @ts-expect-error + f13.fields.file.progress().total = 123; + // @ts-expect-error + f13.fields.file.progress().percent = 123; + // @ts-expect-error + f13.fields.nonfile.progress(); } form_tests(); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index c2b56afff601..da1f0338c822 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -3105,7 +3105,19 @@ declare module '$app/server' { dirty(): boolean; /** 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); // These two types use "T extends unknown ? .. : .." to distribute over unions. // Example: if "type T = A | b" then "keyof T" only contains keys that both A and B have, with "KeysOfUnion" we get the keys of both A and B From 3309b1672eb7efd7d4390c31ce3be7af61a7f67b Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 19:28:56 -0700 Subject: [PATCH 02/10] consolidate tests --- packages/kit/test/apps/async/test/test.js | 41 ++++++----------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index c39d6d39061a..2a5ca49604a3 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -1014,34 +1014,6 @@ test.describe('remote functions', () => { }) ); }); - test('large file uploads work', async ({ page }) => { - await page.goto('/remote/form/file-upload'); - - await page.locator('input[name^="file1"]').setInputFiles({ - name: 'a.txt', - mimeType: 'text/plain', - buffer: Buffer.alloc(1024 * 1024 * 10) - }); - await page.locator('input[name^="deep.files[0]"]').setInputFiles({ - name: 'b.txt', - mimeType: 'text/plain', - buffer: Buffer.from('b') - }); - await page.locator('input[name^="deep.files[1]"]').setInputFiles({ - name: 'cc.txt', - mimeType: 'text/plain', - buffer: Buffer.from('cc') - }); - await page.locator('button').click(); - - await expect(page.locator('pre')).toHaveText( - JSON.stringify({ - text: 'Hello world', - file1: 1024 * 1024 * 10, - file2: [1, 2] - }) - ); - }); test('file upload progress works', async ({ page, context, javaScriptEnabled }) => { if (!javaScriptEnabled) return; @@ -1056,18 +1028,18 @@ test.describe('remote functions', () => { try { const progress = page.locator('#progress1'); expect(progress).toHaveText('{"uploaded":0,"total":0,"percent":0}'); - await page.locator('input[name="file1"]').setInputFiles({ + await page.locator('input[name^="file1"]').setInputFiles({ name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.alloc(1024 * 1024 * 10) }); expect(progress).toHaveText('{"uploaded":0,"total":10485760,"percent":0}'); - await page.locator('input[name="deep.files[0]"]').setInputFiles({ + await page.locator('input[name^="deep.files[0]"]').setInputFiles({ name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') }); - await page.locator('input[name="deep.files[1]"]').setInputFiles({ + await page.locator('input[name^="deep.files[1]"]').setInputFiles({ name: 'c.txt', mimeType: 'text/plain', buffer: Buffer.from('c') @@ -1083,6 +1055,13 @@ test.describe('remote functions', () => { await check_percent(); await expect(progress).not.toHaveText(progress_text); await check_percent(); + await expect(page.locator('pre')).toHaveText( + JSON.stringify({ + text: 'Hello world', + file1: 1024 * 1024 * 10, + file2: [1, 2] + }) + ); await page.locator('#reset').click(); await expect(progress).toHaveText('{"uploaded":0,"total":0,"percent":0}'); } finally { From 010b9534bf0010be18c41847f4d5ee82045417f5 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 19:37:32 -0700 Subject: [PATCH 03/10] fix empty body responses --- .../kit/src/runtime/client/remote-functions/form.svelte.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 5ec42b2ef3a3..62963d29b9d6 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -258,8 +258,9 @@ export function form(id) { const xhr_response = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.addEventListener('load', () => { + const body = (xhr.response ?? xhr.responseText) || null; resolve( - new Response(xhr.response ?? xhr.responseText, { + new Response(body, { status: xhr.status, statusText: xhr.statusText, headers: parse_xhr_headers(xhr.getAllResponseHeaders()) From 845ffc3706ed18b9fb5330e50fdcd1f21793f031 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 19:40:13 -0700 Subject: [PATCH 04/10] correctly measure header byte length --- packages/kit/src/runtime/form-utils.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index 2a787bb26b8a..c6672f98a78c 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -145,7 +145,8 @@ export function serialize_binary_form(data, meta) { /** @type {Array | undefined} */ let file_offsets; - let encoded_file_offsets = ''; + /** @type {Uint8Array} */ + let encoded_file_offsets; /** @type {Array<[file: File, index: number]>} */ let unsorted_files; @@ -161,7 +162,9 @@ export function serialize_binary_form(data, meta) { file_offsets[index] = start; start += file.size; } - encoded_file_offsets = JSON.stringify(file_offsets); + encoded_file_offsets = text_encoder.encode(JSON.stringify(file_offsets)); + } else { + encoded_file_offsets = new Uint8Array(); } const encoded_header_buffer = text_encoder.encode(encoded_header); From 0777e433a4fb641a20c77b4dead2c0f4e3c20e46 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 20:13:00 -0700 Subject: [PATCH 05/10] fix files progress object being mutated by different file --- .../client/remote-functions/form.svelte.js | 11 ++++---- packages/kit/test/apps/async/test/test.js | 25 +++++++++++++++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 62963d29b9d6..debbf8451126 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -273,8 +273,10 @@ export function form(id) { if (file_offsets) { const file_paths = get_file_paths(data); + /** @type {Map} */ + const progress_objects = new Map(); for (const [file, path] of file_paths) { - deep_set(upload_progress, path, { + const progress = $state({ uploaded: 0, total: file.size, get percent() { @@ -282,6 +284,8 @@ export function form(id) { return this.uploaded / this.total; } }); + progress_objects.set(file, progress); + deep_set(upload_progress, path, progress); } xhr.upload.addEventListener('progress', (ev) => { for (const file of file_offsets) { @@ -289,10 +293,7 @@ export function form(id) { 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; - // Could be null if the form was reset mid-upload - const progress = deep_get(upload_progress, path); + const progress = progress_objects.get(file.file); if (!progress) continue; progress.uploaded = uploaded; } diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index 2a5ca49604a3..b260747fa0cb 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -1015,6 +1015,27 @@ test.describe('remote functions', () => { ); }); + const big_file = Buffer.alloc(1024 * 1024 * 10); + + test('replacing a file mid-upload', async ({ page }) => { + await page.goto('/remote/form/file-upload'); + + await page.locator('input[name^="file1"]').setInputFiles({ + name: 'a.txt', + mimeType: 'text/plain', + buffer: big_file + }); + const progress = page.locator('#progress1'); + expect(progress).toHaveText(`{"uploaded":0,"total":${big_file.byteLength},"percent":0}`); + await page.locator('#submit').click(); + await page.locator('input[name^="file1"]').setInputFiles({ + name: 'a.txt', + mimeType: 'text/plain', + buffer: Buffer.from('a') + }); + expect(progress).toHaveText(`{"uploaded":0,"total":1,"percent":0}`); + }); + test('file upload progress works', async ({ page, context, javaScriptEnabled }) => { if (!javaScriptEnabled) return; await page.goto('/remote/form/file-upload'); @@ -1031,7 +1052,7 @@ test.describe('remote functions', () => { await page.locator('input[name^="file1"]').setInputFiles({ name: 'a.txt', mimeType: 'text/plain', - buffer: Buffer.alloc(1024 * 1024 * 10) + buffer: big_file }); expect(progress).toHaveText('{"uploaded":0,"total":10485760,"percent":0}'); await page.locator('input[name^="deep.files[0]"]').setInputFiles({ @@ -1058,7 +1079,7 @@ test.describe('remote functions', () => { await expect(page.locator('pre')).toHaveText( JSON.stringify({ text: 'Hello world', - file1: 1024 * 1024 * 10, + file1: big_file.byteLength, file2: [1, 2] }) ); From f0f4a642848ec0c53105a1ffe34d2dec9babbe67 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 20:16:28 -0700 Subject: [PATCH 06/10] fix test --- packages/kit/test/apps/async/test/test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index b260747fa0cb..1819fcd2a621 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -1080,7 +1080,7 @@ test.describe('remote functions', () => { JSON.stringify({ text: 'Hello world', file1: big_file.byteLength, - file2: [1, 2] + files: [1, 2] }) ); await page.locator('#reset').click(); From e690fecd512d252d38a1e56b47aecbae31da4fa8 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 20:18:10 -0700 Subject: [PATCH 07/10] fix test 2 --- packages/kit/test/apps/async/test/test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index 1819fcd2a621..80703dfd3feb 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -1063,7 +1063,7 @@ test.describe('remote functions', () => { await page.locator('input[name^="deep.files[1]"]').setInputFiles({ name: 'c.txt', mimeType: 'text/plain', - buffer: Buffer.from('c') + buffer: Buffer.from('cc') }); await page.locator('#submit').click(); await expect(progress).not.toHaveText(/"uploaded":0/); From e57e387cc0aa4c452a008c0cd3d2d2cb5f8a9d79 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 20:23:10 -0700 Subject: [PATCH 08/10] fix byteLengths --- packages/kit/src/runtime/form-utils.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index c6672f98a78c..3bd45984cbc5 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -175,7 +175,7 @@ export function serialize_binary_form(data, meta) { length_view.setUint32(0, encoded_header_buffer.byteLength, true); blob_parts.push(length_buffer.slice()); - length_view.setUint16(0, encoded_file_offsets.length, true); + length_view.setUint16(0, encoded_file_offsets.byteLength, true); blob_parts.push(length_buffer.slice(0, 2)); blob_parts.push(encoded_header_buffer); @@ -185,7 +185,8 @@ export function serialize_binary_form(data, meta) { blob_parts.push(file); } - const file_offset_start = HEADER_BYTES + encoded_header.length + encoded_file_offsets.length; + const file_offset_start = + HEADER_BYTES + encoded_header_buffer.byteLength + encoded_file_offsets.byteLength; return { blob: new Blob(blob_parts), From e1dd05e8339a0f9abe714576847240da3366fcb0 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 20:38:15 -0700 Subject: [PATCH 09/10] add parse_xhr_headers tests --- .../client/remote-functions/form.svelte.js | 4 +- .../remote-functions/form.svelte.spec.js | 54 ++++++++++++++++++- packages/kit/src/runtime/form-utils.spec.js | 18 ++++--- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index debbf8451126..e3c4e60b1343 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -896,12 +896,14 @@ function validate_form_data(form_data, enctype) { * @param {string} xhr_headers * @returns {Headers} */ -function parse_xhr_headers(xhr_headers) { +export function parse_xhr_headers(xhr_headers) { const headers = new Headers(); const lines = xhr_headers.trim().split('\n'); for (const line of lines) { const colon_index = line.indexOf(':'); + if (colon_index === -1) continue; const key = line.substring(0, colon_index).trim(); + if (!key) continue; const value = line.substring(colon_index + 1).trim(); headers.append(key, value); } diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.spec.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.spec.js index 213ff9aae018..b2e829e487f6 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.spec.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.spec.js @@ -1,5 +1,6 @@ import { flushSync, tick } from 'svelte'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, it, test, vi } from 'vitest'; +import { parse_xhr_headers } from './form.svelte.js'; const env = vi.hoisted(() => ({ DEV: true })); vi.mock('esm-env', () => ({ @@ -232,3 +233,54 @@ test('form.for keeps its instance when the derived that holds it reconnects', as cleanup(); } }); + +describe('parse_xhr_headers', () => { + it('returns a Headers instance', () => { + expect(parse_xhr_headers('X-A: 1')).toBeInstanceOf(Headers); + }); + + it('parses a single header', () => { + const headers = parse_xhr_headers('Content-Type: text/html'); + expect(headers.get('Content-Type')).toBe('text/html'); + }); + + it('parses multiple headers', () => { + const headers = parse_xhr_headers( + 'Content-Type: application/json\nX-Request-Id: abc123\nCache-Control: no-store' + ); + expect(headers.get('content-type')).toBe('application/json'); + expect(headers.get('x-request-id')).toBe('abc123'); + expect(headers.get('cache-control')).toBe('no-store'); + }); + + it('trims whitespace around names and values', () => { + const headers = parse_xhr_headers(' Content-Type : application/json '); + expect(headers.get('content-type')).toBe('application/json'); + }); + + it('accepts values containing colons', () => { + const headers = parse_xhr_headers('Date: Mon, 23 Oct 2026 10:00:00 GMT'); + expect(headers.get('date')).toBe('Mon, 23 Oct 2026 10:00:00 GMT'); + }); + + it('accepts CRLF line endings', () => { + const headers = parse_xhr_headers('Content-Type: text/plain\r\nX-Foo: bar'); + expect(headers.get('content-type')).toBe('text/plain'); + expect(headers.get('x-foo')).toBe('bar'); + }); + + it('combines repeated names into one comma-joined entry', () => { + const headers = parse_xhr_headers('X-Multi: one\nX-Multi: two'); + expect([...headers.entries()]).toEqual([['x-multi', 'one, two']]); + }); + + it('allows an empty header value', () => { + const headers = parse_xhr_headers('X-Empty: '); + expect(headers.get('x-empty')).toBe(''); + }); + + it('works with empty input', () => { + const headers = parse_xhr_headers(''); + expect([...headers.entries()].length).toBe(0); + }); +}); diff --git a/packages/kit/src/runtime/form-utils.spec.js b/packages/kit/src/runtime/form-utils.spec.js index 6d152c13b656..e8ae7985ebd1 100644 --- a/packages/kit/src/runtime/form-utils.spec.js +++ b/packages/kit/src/runtime/form-utils.spec.js @@ -955,7 +955,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); expect(() => proxy.field.as(type)).toThrowKitError('form_input_missing_value'); }); @@ -966,7 +967,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); expect(proxy.position.as('image')).toEqual({ @@ -990,7 +992,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); const cloned = proxy.created_at.value(); @@ -1010,7 +1013,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({ bad: [] }), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); /** @@ -1085,7 +1089,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({ a: true }) + get_dirty: () => ({ a: true }), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); expect(edited.a.as('number', 3).value).toBe(''); @@ -1100,7 +1105,8 @@ describe('create_field_proxy', () => { set: () => {}, get_issues: () => ({}), get_touched: () => ({}), - get_dirty: () => ({}) + get_dirty: () => ({}), + get_progress: () => ({ uploaded: 0, total: 0, percent: 0 }) }); expect(Symbol.iterator in proxy).toBe(false); From 0a98acca7854deda37053950919c6d1831ac169e Mon Sep 17 00:00:00 2001 From: Ottomated Date: Thu, 22 Oct 2026 22:08:04 -0700 Subject: [PATCH 10/10] fix javascriptEnabled on test --- packages/kit/test/apps/async/test/test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kit/test/apps/async/test/test.js b/packages/kit/test/apps/async/test/test.js index 80703dfd3feb..761af1bf3be5 100644 --- a/packages/kit/test/apps/async/test/test.js +++ b/packages/kit/test/apps/async/test/test.js @@ -1017,7 +1017,8 @@ test.describe('remote functions', () => { const big_file = Buffer.alloc(1024 * 1024 * 10); - test('replacing a file mid-upload', async ({ page }) => { + test('replacing a file mid-upload', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; await page.goto('/remote/form/file-upload'); await page.locator('input[name^="file1"]').setInputFiles({