Skip to content
Closed
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
5 changes: 4 additions & 1 deletion packages/kit/src/core/sync/write_root.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,13 @@ export function write_root(manifest_data, config, output) {
${
isSvelte5Plus()
? dedent`
let { stores, page, constructors, components = [], form, ${use_boundaries ? 'errors = [], error, ' : ''}${levels
let { stores, page, constructors, components = [], form, fork, ${use_boundaries ? 'errors = [], error, ' : ''}${levels
.map((l) => `data_${l} = null`)
.join(', ')} } = $props();
${use_boundaries ? `let data = $derived({${levels.map((l) => `'${l}': data_${l}`).join(', ')}})` : ''}
if (browser) {
setContext('__sveltekit_fork', () => fork);
}
`
: dedent`
export let stores;
Expand Down
38 changes: 36 additions & 2 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -639,10 +639,34 @@ async function _preload_data(intent) {
// resolve, bail rather than creating an orphan fork
if (lc === load_cache && result.type === 'loaded') {
try {
return svelte.fork(() => {
root.$set(result.props);
// We gotta create a fork facade so that we can put something into the props
// the moment the fork callback is run, before the fork object is created.
let committed = false;
let discarded = false;
/** @type {any} */
const fork = {
// TODO have this in fork API?
get committed() {
return committed;
},
get discarded() {
return discarded;
}
};
const f = svelte.fork(() => {
root.$set({ ...result.props, fork });
update(result.props.page);
});
fork.commit = () => {
committed = true;
return f.commit();
};
fork.discard = () => {
discarded = true;
return f.discard();
};
fork.id = lc.id;
return fork;
} catch {
// if it errors, it's because the experimental flag isn't enabled in Svelte
}
Expand Down Expand Up @@ -2090,6 +2114,16 @@ if (import.meta.hot) {
});
}

/**
* @param {import('svelte').Fork} fork
* @param {string} location
*/
export async function redirect_fork(fork, location) {
if ((await load_cache?.fork) === fork && load_cache) {
load_cache.promise = Promise.resolve({ type: 'redirect', location });
}
}

/** @typedef {(typeof PRELOAD_PRIORITIES)['hover'] | (typeof PRELOAD_PRIORITIES)['tap']} PreloadDataPriority */

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@
import { app_dir, base } from '$app/paths/internal/client';
import { version } from '$app/env';
import * as devalue from 'devalue';
import { app, goto, prerender_responses } from '../client.js';
import { get_remote_request_headers, remote_request, unwrap_node } from './shared.svelte.js';
import { app, prerender_responses } from '../client.js';
import {
get_remote_request_headers,
handle_remote_redirect,
is_in_effect,
register_fork,
remote_request,
unwrap_node
} from './shared.svelte.js';
import { create_remote_key, stringify_remote_arg } from '../../shared.js';
import { noop } from '../../../utils/functions.js';

Expand Down Expand Up @@ -59,6 +66,14 @@ export function prerender(id) {
const payload = stringify_remote_arg(arg, app.hooks.transport);
const cache_key = create_remote_key(id, payload);

if (is_in_effect()) {
const release = register_fork(cache_key);

$effect.pre(() => () => {
release();
});
}

let resource = prerender_resources.get(cache_key)?.deref();

if (!resource) {
Expand Down Expand Up @@ -98,7 +113,7 @@ export function prerender(id) {
const result = await remote_request(url, { headers });

if (result.redirect) {
void goto(result.redirect);
await handle_remote_redirect(cache_key, result.redirect);
return;
}

Expand Down
12 changes: 9 additions & 3 deletions packages/kit/src/runtime/client/remote-functions/query/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
/** @import { RemoteQueryFunction } from '@sveltejs/kit' */
import { app_dir, base } from '$app/paths/internal/client';
import { goto, query_map } from '../../client.js';
import { get_remote_request_headers, QUERY_FUNCTION_ID, remote_request } from '../shared.svelte.js';
import { query_map } from '../../client.js';
import {
get_remote_request_headers,
handle_remote_redirect,
QUERY_FUNCTION_ID,
remote_request
} from '../shared.svelte.js';
import { DEV } from 'esm-env';
import { QueryProxy } from './proxy.js';
import { create_remote_key } from '../../../shared.js';

/**
* @param {string} id
Expand All @@ -29,7 +35,7 @@ export function query(id) {
const result = await remote_request(url, { headers: get_remote_request_headers() });

if (result.redirect) {
await goto(result.redirect);
await handle_remote_redirect(create_remote_key(id, payload), result.redirect);
}
});
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { app, query_map } from '../../client.js';
import {
is_in_effect,
pin_in_effect,
pin_while_resolving,
QUERY_OVERRIDE_KEY,
QUERY_RESOURCE_KEY
QUERY_RESOURCE_KEY,
register_fork_in_effect
} from '../shared.svelte.js';
import { create_remote_key, stringify_remote_arg } from '../../../shared.js';
import { Query } from './instance.svelte.js';
Expand Down Expand Up @@ -45,6 +47,10 @@ export class QueryProxy {
);

cache.ref(this, entry, this.#id, this.#payload);

if (is_in_effect()) {
register_fork_in_effect(this.#key);
}
}

#get_cached_query() {
Expand Down
110 changes: 106 additions & 4 deletions packages/kit/src/runtime/client/remote-functions/shared.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
/** @import { RemoteQueryUpdate } from '@sveltejs/kit' */
/** @import { CacheEntry } from './cache.svelte.js' */
import * as devalue from 'devalue';
import { app, goto, live_query_map, query_map, query_responses } from '../client.js';
import { app, goto, live_query_map, query_map, query_responses, redirect_fork } from '../client.js';
import { HttpError, Redirect } from '@sveltejs/kit/internal';
import { untrack } from 'svelte';
import { getContext, untrack } from 'svelte';
import { create_remote_key, split_remote_key } from '../../shared.js';
import { navigating, page } from '../state.svelte.js';

Expand All @@ -15,6 +15,81 @@ export const QUERY_OVERRIDE_KEY = Symbol('sveltekit.query_override_key');
/** Indicates a query instance */
export const QUERY_RESOURCE_KEY = Symbol('sveltekit.query_resource_key');

/** @typedef {import('svelte').Fork & { discarded: boolean; committed: boolean }} SvelteKitFork */

/** @type {Map<string, Map<SvelteKitFork | null, number>>} */
const forks_by_key = new Map();

/**
* @returns {() => SvelteKitFork | null}
*/
function get_fork_context() {
try {
return getContext('__sveltekit_fork');
} catch {
return () => null;
}
}

/**
* @param {string} key
* @returns {() => void}
*/
export function register_fork(key) {
const get_fork = get_fork_context()();
const instances = forks_by_key.get(key) ?? new Map();

instances.set(get_fork, (instances.get(get_fork) ?? 0) + 1);
forks_by_key.set(key, instances);

return () => {
const current = forks_by_key.get(key);
if (!current) return;

const count = current.get(get_fork);
if (count === undefined) return;

if (count > 1) {
current.set(get_fork, count - 1);
} else {
current.delete(get_fork);
}

if (current.size === 0) {
forks_by_key.delete(key);
}
};
}

/**
* @param {string} key
* @param {string} location
*/
export async function handle_remote_redirect(key, location) {
const forks = forks_by_key.get(key) ?? new Map();
let target;

for (const fork of forks.keys()) {
if (!fork || fork.committed) {
await goto(location);
throw new Redirect(307, location);
} else if (!fork.discarded) {
target = fork;
}
}

if (target) {
await redirect_fork(target, location);
// This request happened in a speculative fork and has been routed through the fork loader.
// Keep the promise pending to avoid turning the redirect into a render error in the current world.
// TODO this is a Svelte bug we need to fix that
return new Promise(() => {});
}

await goto(location);
throw new Redirect(307, location);
}

/**
* If we're inside a reactive context, pin a cache entry for as long as the
* surrounding effect is alive. Without this, a transiently-referenced
Expand Down Expand Up @@ -48,8 +123,8 @@ export function pin_in_effect(cache_map, cache, id, payload) {
* cache entry stays pinned for the lifetime of the awaited promise. Without
* this, a proxy awaited outside any effect (e.g. in an event handler) could
* be GC'd between the `.then` getter returning the thenable and the
* underlying promise settling, causing the FinalizationRegistry to evict the
* cache entry mid-flight and the awaited value to resolve to Svelte's
* underlying promise settling, causing the FinalizationRegistry to evict
* the cache entry mid-flight and the awaited value to resolve to Svelte's
* `UNINITIALIZED` sentinel from a torn-down `$derived`.
*
* @template TResource
Expand Down Expand Up @@ -276,3 +351,30 @@ export function categorize_updates(updates) {

return { overrides, refreshes };
}

/**
* @returns {boolean} Returns `true` if we are in an effect
*/
export function is_in_effect() {
try {
$effect.pre(() => {});
return true;
} catch {
return false;
}
}

/**
* Registers a fork for the given key and ties its cleanup to the surrounding
* effect. Must be called from within a reactive context (i.e. when
* `is_in_effect()` returns `true`).
*
* @param {string} key
*/
export function register_fork_in_effect(key) {
const release = register_fork(key);

$effect.pre(() => () => {
release();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<a href="/remote/prerender/whole-page">whole-page</a>
<a href="/remote/prerender/functions-only">functions-only</a>
<a data-sveltekit-preload-data="hover" href="/remote/prerender/redirect">redirect</a>

<button id="fetch-prerendered" onclick={async () => (prerendered_result = await prerendered())}>
{prerendered_result}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script>
import { page } from '$app/state';
import { layout_redirect } from './redirect.remote.js';

let { children } = $props();
</script>

<p id="layout-prerender-query">
on page {await layout_redirect(page.url.pathname)} (== {page.url.pathname})
</p>

{@render children()}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>should never see this</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { prerender } from '$app/server';
import { redirect } from '@sveltejs/kit';

export const layout_redirect = prerender('unchecked', (path) => {
if (path !== '/remote/prerender/redirect/redirected') {
redirect(307, '/remote/prerender/redirect/redirected');
}

return path;
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p id="redirected">redirected</p>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- TODO can remove this when preloading doesn't eagerly redirect -->
<a data-sveltekit-preload-data="off" href="/remote/query-redirect/from-page">from page</a>
<a data-sveltekit-preload-data="off" href="/remote/query-redirect/from-common-layout">from layout</a
<a data-sveltekit-preload-data="hover" href="/remote/query-redirect/from-page">from page</a>
<a data-sveltekit-preload-data="hover" href="/remote/query-redirect/from-common-layout"
>from layout</a
>
32 changes: 32 additions & 0 deletions packages/kit/test/apps/async/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,38 @@ test.describe('remote functions', () => {
await expect(page.locator('p')).toHaveText('foobaz');
});

test('prerender preload does not eagerly navigate redirects', async ({ page }) => {
await page.goto('/remote/prerender');
await page.locator('a[href="/remote/prerender/redirect"]').hover();
await Promise.all([page.waitForTimeout(100), page.waitForLoadState('networkidle')]);

await expect(page).toHaveURL('/remote/prerender');

await page.click('a[href="/remote/prerender/redirect"]');
await expect(page.locator('#redirected')).toHaveText('redirected');
await expect(page.locator('#layout-prerender-query')).toHaveText(
'on page /remote/prerender/redirect/redirected (== /remote/prerender/redirect/redirected)'
);
});

test('query preload does not eagerly navigate redirects', async ({ page }) => {
await page.goto('/remote/query-redirect');

await page.locator('a[href="/remote/query-redirect/from-page"]').hover();
await Promise.all([page.waitForTimeout(100), page.waitForLoadState('networkidle')]);
await expect(page).toHaveURL('/remote/query-redirect');

await page.locator('a[href="/remote/query-redirect/from-common-layout"]').hover();
await Promise.all([page.waitForTimeout(100), page.waitForLoadState('networkidle')]);
await expect(page).toHaveURL('/remote/query-redirect');

await page.click('a[href="/remote/query-redirect/from-common-layout"]');
await expect(page.locator('#redirected')).toHaveText('redirected');
await expect(page.locator('#layout-query')).toHaveText(
'on page /remote/query-redirect/from-common-layout/redirected (== /remote/query-redirect/from-common-layout/redirected)'
);
});

test('remote query responses are not cacheable', async ({ page }) => {
// the query is kicked off during SSR but fetched by the client after
// hydration, so we can observe the response headers on the wire
Expand Down
Loading