Repository: sveltejs/kit (js)

Issue:
Links without an href wrongly refocus an autofocus input
### Describe the bug

Obviously an anchor without an href is ... wrong. But it looks like they produce incorrect behavior in SvelteKit, at least with respect to re-focusing an input with autofocus.

An anchor with no href re-focuses your input with autofocus.

An anchor with an href, but which calls preventDefault behaves correctly, and does not

### Reproduction

https://github.com/arackaf/sveltekit-autofocus-bug-repro

### Logs

```Shell
n/a
```


### System Info

```Shell
System:
    OS: macOS 13.0.1
    CPU: (16) x64 Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
    Memory: 77.12 MB / 16.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 16.11.1 - /usr/local/Cellar/nvm/0.38.0/versions/node/v16.11.1/bin/node
    Yarn: 1.22.17 - /usr/local/Cellar/nvm/0.38.0/versions/node/v16.11.1/bin/yarn
    npm: 8.18.0 - ~/Documents/node_modules/.bin/npm
  Browsers:
    Chrome: 108.0.5359.98
  npmPackages:
    @sveltejs/adapter-auto: next => 1.0.0-next.90 
    @sveltejs/kit: next => 1.0.0-next.581 
    svelte: ^3.54.0 => 3.54.0 
    vite: ^4.0.0 => 4.0.0
```


### Severity

very minor annoyance

### Additional Information

_No response_

Pull request:
ignore `<a>` elements with no `href`
fixes #8051 

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- [x] This message body should clearly illustrate what problems it solves.
- [x] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [x] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running `pnpm changeset` and following the prompts. All changesets should be `patch` until SvelteKit 1.0


Patch:
diff --git a/.changeset/cyan-mirrors-cry.md b/.changeset/cyan-mirrors-cry.md
new file mode 100644
index 000000000..a67ce3364
--- /dev/null
+++ b/.changeset/cyan-mirrors-cry.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] ignore `<a>` elements with no `href` attribute
diff --git a/.changeset/friendly-cycles-roll.md b/.changeset/friendly-cycles-roll.md
new file mode 100644
index 000000000..1583855eb
--- /dev/null
+++ b/.changeset/friendly-cycles-roll.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] invalidate implicit dependencies in server load functions
diff --git a/.changeset/rare-nails-guess.md b/.changeset/rare-nails-guess.md
deleted file mode 100644
index 9b2df8985..000000000
--- a/.changeset/rare-nails-guess.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-Add `assetFileNames` to worker rollup options
diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md
index 6b09a5a25..96204e97a 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,17 @@
 # @sveltejs/kit
 
+## 1.0.10
+
+### Patch Changes
+
+- Skip inline cache when vary header is present ([#8406](https://github.com/sveltejs/kit/pull/8406))
+
+## 1.0.9
+
+### Patch Changes
+
+- Add `assetFileNames` to worker rollup options ([#8384](https://github.com/sveltejs/kit/pull/8384))
+
 ## 1.0.8
 
 ### Patch Changes
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 72a7dbd59..cb3005c62 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/kit",
-	"version": "1.0.8",
+	"version": "1.0.10",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/kit/src/runtime/client/utils.js b/packages/kit/src/runtime/client/utils.js
index aa14bcce7..25d3980a9 100644
--- a/packages/kit/src/runtime/client/utils.js
+++ b/packages/kit/src/runtime/client/utils.js
@@ -105,7 +105,7 @@ function parent_element(element) {
  */
 export function find_anchor(element, target) {
 	while (element && element !== target) {
-		if (element.nodeName.toUpperCase() === 'A') {
+		if (element.nodeName.toUpperCase() === 'A' && element.hasAttribute('href')) {
 			return /** @type {HTMLAnchorElement | SVGAElement} */ (element);
 		}
 
diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js
index 7b12a46fe..07d52db5f 100644
--- a/packages/kit/src/runtime/server/page/load_data.js
+++ b/packages/kit/src/runtime/server/page/load_data.js
@@ -33,6 +33,12 @@ export async function load_server_data({ event, options, state, node, parent })
 
 	const result = await node.server.load?.call(null, {
 		...event,
+		fetch: (info, init) => {
+			const url = new URL(info instanceof Request ? info.url : info, event.url);
+			uses.dependencies.add(url.href);
+
+			return event.fetch(info, init);
+		},
 		/** @param {string[]} deps */
 		depends: (...deps) => {
 			for (const dep of deps) {
diff --git a/packages/kit/src/runtime/server/page/serialize_data.js b/packages/kit/src/runtime/server/page/serialize_data.js
index 3b278657b..5b275b0c6 100644
--- a/packages/kit/src/runtime/server/page/serialize_data.js
+++ b/packages/kit/src/runtime/server/page/serialize_data.js
@@ -46,6 +46,7 @@ export function serialize_data(fetched, filter, prerendering = false) {
 
 	let cache_control = null;
 	let age = null;
+	let vary = false;
 
 	for (const [key, value] of fetched.response.headers) {
 		if (filter(key, value)) {
@@ -54,6 +55,7 @@ export function serialize_data(fetched, filter, prerendering = false) {
 
 		if (key === 'cache-control') cache_control = value;
 		if (key === 'age') age = value;
+		if (key === 'vary') vary = true;
 	}
 
 	const payload = {
@@ -75,7 +77,11 @@ export function serialize_data(fetched, filter, prerendering = false) {
 		attrs.push(`data-hash=${escape_html_attr(hash(fetched.request_body))}`);
 	}
 
-	if (!prerendering && fetched.method === 'GET' && cache_control) {
+	// Compute the time the response should be cached, taking into account max-age and age.
+	// Do not cache at all if a vary header is present, as this indicates that the cache is
+	// likely to get busted. It would also mean we'd have to add more logic to computing the
+	// selector on the client which results in more code for 99% of people for the 1% who use vary.
+	if (!prerendering && fetched.method === 'GET' && cache_control && !vary) {
 		const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control);
 		if (match) {
 			const ttl = +match[1] - +(age ?? '0');
diff --git a/packages/kit/src/runtime/server/page/serialize_data.spec.js b/packages/kit/src/runtime/server/page/serialize_data.spec.js
index 57ef9ee14..186833710 100644
--- a/packages/kit/src/runtime/server/page/serialize_data.spec.js
+++ b/packages/kit/src/runtime/server/page/serialize_data.spec.js
@@ -61,4 +61,46 @@ test('escapes the attribute values', () => {
 	);
 });
 
+test('computes ttl using cache-control and age headers', () => {
+	const raw = 'an "attr" & a \ud800';
+	const escaped = 'an &quot;attr&quot; &amp; a &#55296;';
+	const response_body = '';
+	assert.equal(
+		serialize_data(
+			{
+				url: raw,
+				method: 'GET',
+				request_body: null,
+				response_body,
+				response: new Response(response_body, {
+					headers: { 'cache-control': 'max-age=10', age: '1' }
+				})
+			},
+			() => false
+		),
+		`<script type="application/json" data-sveltekit-fetched data-url="${escaped}" data-ttl="9">{"status":200,"statusText":"","headers":{},"body":\"\"}</script>`
+	);
+});
+
+test('doesnt compute ttl when vary header is present', () => {
+	const raw = 'an "attr" & a \ud800';
+	const escaped = 'an &quot;attr&quot; &amp; a &#55296;';
+	const response_body = '';
+	assert.equal(
+		serialize_data(
+			{
+				url: raw,
+				method: 'GET',
+				request_body: null,
+				response_body,
+				response: new Response(response_body, {
+					headers: { 'cache-control': 'max-age=10', vary: 'accept-encoding' }
+				})
+			},
+			() => false
+		),
+		`<script type="application/json" data-sveltekit-fetched data-url="${escaped}">{"status":200,"statusText":"","headers":{},"body":\"\"}</script>`
+	);
+});
+
 test.run();


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/server-fetch/+page.server.js b/packages/kit/test/apps/basics/src/routes/load/invalidation/server-fetch/+page.server.js
new file mode 100644
index 000000000..9fe91fcff
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/server-fetch/+page.server.js
@@ -0,0 +1,5 @@
+/** @type {import('./$types').PageServerLoad} */
+export async function load({ fetch }) {
+	const res = await fetch('/load/invalidation/server-fetch/count.json');
+	return res.json();
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/server-fetch/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/server-fetch/+page.svelte
new file mode 100644
index 000000000..6f25f1b70
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/server-fetch/+page.svelte
@@ -0,0 +1,5 @@
+<script>
+	export let data;
+</script>
+
+<p data-testid="count">{data.count}</p>
diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/server-fetch/count.json/+server.js b/packages/kit/test/apps/basics/src/routes/load/invalidation/server-fetch/count.json/+server.js
new file mode 100644
index 000000000..11116223e
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/server-fetch/count.json/+server.js
@@ -0,0 +1,9 @@
+import { json } from '@sveltejs/kit';
+
+let count = 0;
+
+/** @type {import('./$types').RequestHandler} */
+export function GET({ url }) {
+	if (url.searchParams.has('reset')) count = 0;
+	return json({ count: count++ });
+}
diff --git a/packages/kit/test/apps/basics/src/routes/routing/missing-href/+page.svelte b/packages/kit/test/apps/basics/src/routes/routing/missing-href/+page.svelte
new file mode 100644
index 000000000..c9b90c330
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/routing/missing-href/+page.svelte
@@ -0,0 +1,12 @@
+<script>
+	import { afterNavigate } from '$app/navigation';
+
+	let count = 0;
+
+	afterNavigate(() => {
+		count += 1;
+	});
+</script>
+
+<!-- svelte-ignore a11y-missing-attribute -->
+<a data-testid="count">count: {count}</a>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 0a8cc4c66..6ea8f881f 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -789,19 +789,15 @@ test.describe('Routing', () => {
 	});
 
 	test('does not normalize external path', async ({ page }) => {
-		/** @type {Array<string|undefined>} */
-		const urls = [];
-
-		const { port, close } = await start_server((req, res) => {
-			if (req.url !== '/favicon.ico') urls.push(req.url);
-			res.end('ok');
+		const { port, close } = await start_server((_req, res) => {
+			res.end('<html><head></head><body>ok</body></html>');
 		});
 
 		try {
 			await page.goto(`/routing/slashes?port=${port}`);
 			await page.locator(`a[href="http://localhost:${port}/with-slash/"]`).click();
-
-			expect(urls).toEqual(['/with-slash/']);
+			expect(await page.content()).toBe('<html><head></head><body>ok</body></html>');
+			expect(page.url()).toBe(`http://localhost:${port}/with-slash/`);
 		} finally {
 			await close();
 		}
@@ -846,6 +842,16 @@ test.describe('Routing', () => {
 		expect(await page.textContent('h2')).toBe('form');
 		expect(await page.textContent('h3')).toBe('bar');
 	});
+
+	test('ignores links with no href', async ({ page }) => {
+		await page.goto('/routing/missing-href');
+		const selector = '[data-testid="count"]';
+
+		expect(await page.textContent(selector)).toBe('count: 1');
+
+		await page.locator(selector).click();
+		expect(await page.textContent(selector)).toBe('count: 1');
+	});
 });
 
 test.describe('Shadow DOM', () => {
@@ -1022,6 +1028,16 @@ test.describe('Invalidation', () => {
 		expect(shared).not.toBe(next_shared);
 	});
 
+	test('fetch in server load can be invalidated', async ({ page, app, request }) => {
+		await request.get('/load/invalidation/server-fetch/count.json?reset');
+		await page.goto('/load/invalidation/server-fetch');
+		const selector = '[data-testid="count"]';
+
+		expect(await page.textContent(selector)).toBe('1');
+		await app.invalidate('/load/invalidation/server-fetch/count.json');
+		expect(await page.textContent(selector)).toBe('2');
+	});
+
 	test('+layout.js is re-run when shared dep is invalidated', async ({ page }) => {
 		await page.goto('/load/invalidation/depends');
 		const server = await page.textContent('p.server');
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index 9d7eeaa95..b6a8c844c 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -1254,7 +1254,7 @@ test.describe('Redirects', () => {
 	test('prevents redirect loops', async ({ baseURL, page, javaScriptEnabled, browserName }) => {
 		await page.goto('/redirect');
 
-		await page.click('[href="/redirect/loopy/a"]');
+		await page.locator('[href="/redirect/loopy/a"]').click();
 
 		if (javaScriptEnabled) {
 			await page.waitForSelector('#message');
@@ -1447,8 +1447,7 @@ test.describe('Routing', () => {
 
 	test('does not attempt client-side navigation to server routes', async ({ page }) => {
 		await page.goto('/routing');
-		await page.click('[href="/routing/ambiguous/ok.json"]');
-		await page.waitForLoadState('networkidle');
+		await page.locator('[href="/routing/ambiguous/ok.json"]').click();
 		expect(await page.textContent('body')).toBe('ok');
 	});
 
@@ -1517,7 +1516,7 @@ test.describe('Routing', () => {
 	}) => {
 		await page.goto('/routing/hashes/a');
 
-		await page.click('[href="#hash-target"]');
+		await page.locator('[href="#hash-target"]').click();
 		await clicknav('[href="/routing/hashes/b"]');
 
 		await page.goBack();
@@ -1581,6 +1580,7 @@ test.describe('Routing', () => {
 			await page.goto(`/routing?port=${port}`);
 			await Promise.all([
 				page.click(`[href="http://localhost:${port}"]`),
+				// assert that the app can visit a URL not owned by the app without crashing
 				page.waitForURL(`http://localhost:${port}/`)
 			]);
 		} finally {
@@ -1613,7 +1613,7 @@ test.describe('Routing', () => {
 		expect(await page.textContent('h1')).toBe('xyz/abc');
 		expect(await page.textContent('h2')).toBe('xyz/abc');
 
-		await page.click('[href="/routing/rest/xyz/abc/qwe/deep.json"]');
+		await page.locator('[href="/routing/rest/xyz/abc/qwe/deep.json"]').click();
 		expect(await page.textContent('body')).toBe('xyz/abc/qwe');
 	});
 
@@ -1668,7 +1668,7 @@ test.describe('Routing', () => {
 		clicknav
 	}) => {
 		await page.goto('/routing/cancellation');
-		await page.click('[href="/routing/cancellation/a"]');
+		await page.locator('[href="/routing/cancellation/a"]').click();
 		await clicknav('[href="/routing/cancellation/b"]');
 
 		expect(await page.url()).toBe(`${baseURL}/routing/cancellation/b`);
@@ -1849,10 +1849,10 @@ test.describe('Actions', () => {
 		expect(await page.textContent('pre')).toBe(JSON.stringify(null));
 
 		if (javaScriptEnabled) {
-			await page.click('button.increment-success');
+			await page.locator('button.increment-success').click();
 			await expect(page.locator('pre')).toHaveText(JSON.stringify({ count: 0 }));
 
-			await page.click('button.increment-invalid');
+			await page.locator('button.increment-invalid').click();
 			await expect(page.locator('pre')).toHaveText(JSON.stringify({ count: 1 }));
 		}
 	});
@@ -1866,10 +1866,10 @@ test.describe('Actions', () => {
 		expect(await page.textContent('pre')).toBe(JSON.stringify(null));
 
 		if (javaScriptEnabled) {
-			await page.click('button.increment-success');
+			await page.locator('button.increment-success').click();
 			await expect(page.locator('pre')).toHaveText(JSON.stringify({ count: 0 }));
 
-			await page.click('button.invalidateAll');
+			await page.locator('button.invalidateAll').click();
 			await page.waitForTimeout(500);
 			await expect(page.locator('pre')).toHaveText(JSON.stringify({ count: 0 }));
 			await app.goto('/actions/enhance');
@@ -1885,7 +1885,7 @@ test.describe('Actions', () => {
 		expect(await page.textContent('pre')).toBe(JSON.stringify(null));
 
 		if (javaScriptEnabled) {
-			await page.click('button.redirect');
+			await page.locator('button.redirect').click();
 			await expect(page.locator('footer')).toHaveText('Custom layout');
 		}
 	});
@@ -1895,7 +1895,7 @@ test.describe('Actions', () => {
 		expect(await page.textContent('pre')).toBe(JSON.stringify(null));
 
 		if (javaScriptEnabled) {
-			await page.click('button.error');
+			await page.locator('button.error').click();
 			await expect(page.locator('p')).toHaveText(
 				'This is your custom error page saying: "Unexpected Form Error"'
 			);
@@ -2060,11 +2060,11 @@ test.describe.serial('Cookies API', () => {
 		let span = page.locator('#cookie-value');
 		expect(await span.innerText()).toContain('undefined');
 
-		await page.click('button#teapot');
+		await page.locator('button#teapot').click();
 		await expect(page.locator('#cookie-value')).toHaveText('teapot');
 
 		// setting a different value...
-		await page.click('button#janeAusten');
+		await page.locator('button#janeAusten').click();
 		await expect(page.locator('#cookie-value')).toHaveText('Jane Austen');
 	});
 
diff --git a/packages/kit/test/prerendering/basics/test/test.js b/packages/kit/test/prerendering/basics/test/test.js
index f4bf390fd..5304db890 100644
--- a/packages/kit/test/prerendering/basics/test/test.js
+++ b/packages/kit/test/prerendering/basics/test/test.js
@@ -188,7 +188,9 @@ test('fetches data from local endpoint', () => {
 			{
 				type: 'data',
 				data: [{ message: 1 }, 'hello'],
-				uses: {}
+				uses: {
+					dependencies: ['http://example.com/origin/message.json']
+				}
 			}
 		]
 	});