Skip to content
Merged
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: 5 additions & 0 deletions .changeset/fix-hmr-slots-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

fix(hmr): eagerly recompile on style-only change to prevent stale slots render
5 changes: 5 additions & 0 deletions .changeset/frank-actors-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `InferLoaderSchema` type inference for content collections defined with a loader that includes a `schema`
14 changes: 14 additions & 0 deletions .changeset/proud-apples-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@astrojs/markdoc': patch
'@astrojs/preact': patch
'@astrojs/svelte': patch
'@astrojs/react': patch
'@astrojs/solid-js': patch
'@astrojs/mdx': patch
'create-astro': patch
'@astrojs/prism': patch
'@astrojs/upgrade': patch
'astro': patch
---

Removes temporary support for Node >=20.19.1 because Stackblitz now uses Node 22 by default
2 changes: 1 addition & 1 deletion packages/astro-prism/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,6 @@
"astro-scripts": "workspace:*"
},
"engines": {
"node": "^20.19.1 || >=22.12.0"
"node": ">=22.12.0"
}
}
7 changes: 2 additions & 5 deletions packages/astro/bin/astro.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@ const CI_INSTRUCTIONS = {
VERCEL: 'https://vercel.com/docs/runtimes#official-runtimes/node-js/node-js-version',
};

// TODO: remove once Stackblitz supports Node 22
const IS_STACKBLITZ = !!process.versions.webcontainer;

// Hardcode supported Node.js version so we don't have to read differently in CJS & ESM.
const engines = IS_STACKBLITZ ? '>=20.19.1' : '>=22.12.0';
const skipSemverCheckIfAbove = IS_STACKBLITZ ? 21 : 23;
const engines = '>=22.12.0';
const skipSemverCheckIfAbove = 23;

/** `astro *` */
async function main() {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@
"vitest": "^3.2.4"
},
"engines": {
"node": "^20.19.1 || >=22.12.0",
"node": ">=22.12.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0"
},
Expand Down
18 changes: 14 additions & 4 deletions packages/astro/src/vite-plugin-astro/hmr.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import type { HmrContext } from 'vite';
import type { Logger } from '../core/logger/core.js';
import type { CompileAstroResult } from './compile.js';
import { parseAstroRequest } from './query.js';
import type { CompileMetadata } from './types.js';
import { frontmatterRE } from './utils.js';

interface HandleHotUpdateOptions {
logger: Logger;
compile: (code: string, filename: string) => Promise<CompileAstroResult>;
astroFileToCompileMetadata: Map<string, CompileMetadata>;
}

export async function handleHotUpdate(
ctx: HmrContext,
{ logger, astroFileToCompileMetadata }: HandleHotUpdateOptions,
{ logger, compile, astroFileToCompileMetadata }: HandleHotUpdateOptions,
) {
// HANDLING 1: Invalidate compile metadata if CSS dependency updated
//
Expand All @@ -35,9 +37,17 @@ export async function handleHotUpdate(

if (isStyleOnlyChanged(oldCode, newCode)) {
logger.debug('watch', 'style-only change');
// Invalidate its `astroFileToCompileMetadata` so that the next transform of Astro style virtual module
// will re-generate it
astroFileToCompileMetadata.delete(ctx.file);
// Eagerly re-compile to update the metadata with the new CSS. This ensures
// the compile metadata stays consistent so that subsequent SSR requests
// (e.g. page refresh) can load the style virtual modules without needing
// to re-compile from disk, avoiding potential stale state.
try {
await compile(newCode, ctx.file);
} catch {
// If re-compilation fails, fall back to deleting the metadata so the
// load hook will re-compile lazily on the next request.
astroFileToCompileMetadata.delete(ctx.file);
}
return ctx.modules.filter((mod) => {
if (!mod.id) {
return false;
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/vite-plugin-astro/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export default function astro({ settings, logger }: AstroPluginOptions): vite.Pl
},
},
async handleHotUpdate(ctx) {
return handleHotUpdate(ctx, { logger, astroFileToCompileMetadata });
return handleHotUpdate(ctx, { logger, compile, astroFileToCompileMetadata });
},
},
{
Expand Down
5 changes: 3 additions & 2 deletions packages/astro/templates/content/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,12 @@ declare module 'astro:content' {
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = Required<ContentConfig['collections'][C]>['loader'],
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<Required<ContentConfig['collections'][C]>['loader']['schema']>
? import('astro/zod').infer<L['schema']>
: any;

type DataEntryMap = {
Expand Down
83 changes: 83 additions & 0 deletions packages/astro/test/content-collections-type-inference.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @ts-check
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
import { before, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { loadFixture } from './test-utils.js';

describe('Content collection type inference', () => {
/** @type {Awaited<ReturnType<typeof loadFixture>>} */
let fixture;
/** @type {string} */
let fixtureRoot;

before(async () => {
fixture = await loadFixture({
root: './fixtures/content-collections-type-inference/',
});
fixtureRoot = fileURLToPath(fixture.config.root);

// Clean previous .astro directory
fs.rmSync(new URL('./.astro/', fixture.config.root), { force: true, recursive: true });

// Run astro sync to generate .astro/content.d.ts from the real template
await fixture.sync({ root: fixtureRoot });
});

it('generates content.d.ts with ExtractLoaderConfig type utility', async () => {
const contentDts = fs.readFileSync(
new URL('./.astro/content.d.ts', fixture.config.root),
'utf-8',
);
assert.ok(
contentDts.includes('ExtractLoaderConfig'),
'Generated content.d.ts should contain ExtractLoaderConfig type utility',
);
assert.ok(
contentDts.includes('InferLoaderSchema'),
'Generated content.d.ts should contain InferLoaderSchema type utility',
);
});

it('generates correct DataEntryMap with all three collection types', async () => {
const contentDts = fs.readFileSync(
new URL('./.astro/content.d.ts', fixture.config.root),
'utf-8',
);
assert.ok(contentDts.includes('"blog"'), 'DataEntryMap should include "blog" collection');
assert.ok(contentDts.includes('"legacy"'), 'DataEntryMap should include "legacy" collection');
assert.ok(
contentDts.includes('"schemaless"'),
'DataEntryMap should include "schemaless" collection',
);
});

it('type-checks correctly against the generated types', () => {
// Run tsc on the fixture to verify the type assertions in src/type-checks.ts
// pass against the real generated content.d.ts.
//
// The type-checks.ts file uses @ts-expect-error to assert that:
// - Case 1 (loader with schema): data is NOT any, is { test: string }
// - Case 2 (legacy schema): data is NOT any, is { title: string; legacyField: boolean }
// - Case 3 (schemaless loader): data IS any (the correct fallback)
//
// If any @ts-expect-error is unused (type collapsed to `any` when it shouldn't),
// tsc will report an error and this test fails.
try {
execSync('npx tsc --noEmit', {
cwd: fixtureRoot,
stdio: 'pipe',
encoding: 'utf-8',
});
} catch (err) {
const stdout = /** @type {{ stdout?: string }} */ (err).stdout ?? '';
const stderr = /** @type {{ stderr?: string }} */ (err).stderr ?? '';
assert.fail(
`TypeScript type-checking failed on fixture.\n` +
`This means the content collection type inference is broken.\n\n` +
`stdout:\n${stdout}\n\nstderr:\n${stderr}`,
);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'astro/config';

export default defineConfig({});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/content-collections-type-inference",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import type { Loader } from 'astro/loaders';

function myLoader() {
return {
name: 'my-loader',
load: async () => {},
schema: z.object({
test: z.string(),
}),
} satisfies Loader;
}

// Case 1: Loader with schema defined on the loader object
const blog = defineCollection({
loader: myLoader(),
});

// Case 2: Legacy collection with schema on the collection (no loader)
const legacy = defineCollection({
schema: z.object({
title: z.string(),
legacyField: z.boolean(),
}),
});

// Case 3: Loader with no schema at all
const schemaless = defineCollection({
loader: async () => [{ id: '1' }],
});

export const collections = {
blog,
legacy,
schemaless,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<html><body><h1>Type Inference Test Fixture</h1></body></html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Type assertions for content collection type inference.
*
* This file is NOT executed at runtime. It is type-checked by tsc after
* `astro sync` generates the .astro/content.d.ts types for this fixture.
*
* Each @ts-expect-error comment asserts that the line below it IS a type error,
* meaning the type is NOT `any` or `never` where it shouldn't be.
*
* If the patch in templates/content/types.d.ts regresses, tsc will fail here
* because a @ts-expect-error will become unused (the type collapsed to `any`).
*/
import type { CollectionEntry, InferLoaderSchema } from 'astro:content';

// ============================================================================
// Case 1: Loader with schema on the loader object ("blog" collection)
// The patched ExtractLoaderConfig should correctly extract the loader's schema.
// ============================================================================

type BlogEntry = CollectionEntry<'blog'>;
type BlogData = BlogEntry['data'];

// BlogData should be { test: string }, NOT any.
// If it were `any`, assigning a number to a string field would not error.
// @ts-expect-error - `test` is string, not number
const _blogDataCheck: BlogData = { test: 123 };

type InferredBlogSchema = InferLoaderSchema<'blog'>;
// @ts-expect-error - `test` is string, not number
const _inferredBlogCheck: InferredBlogSchema = { test: 123 };

// ============================================================================
// Case 2: Legacy collection with schema on the collection ("legacy")
// Should NOT be broken by the ExtractLoaderConfig patch.
// ============================================================================

type LegacyData = CollectionEntry<'legacy'>['data'];

// LegacyData should be { title: string; legacyField: boolean }.
// @ts-expect-error - `title` is string, not number
const _legacyTitleCheck: LegacyData = { title: 123, legacyField: true };
// @ts-expect-error - `legacyField` is boolean, not string
const _legacyFieldCheck: LegacyData = { title: 'ok', legacyField: 'not a boolean' };

// ============================================================================
// Case 3: Loader with no schema ("schemaless" collection)
// Should fall back to `any` — this is the correct behavior.
// ============================================================================

type SchemalessData = InferLoaderSchema<'schemaless'>;

// If the type is correctly `any`, then any assignment is valid and
// a ts-expect-error on a valid assignment would be an error itself.
// So we verify `any` by checking that arbitrary property access works:
const _schemalessValue: SchemalessData = { anything: 'goes', count: 42 };
const _schemalessAccess: string = _schemalessValue.nonExistentProp;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'astro/config';

export default defineConfig({});
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/hmr-slots-render/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/hmr-slots-render",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
type Props = {
items: unknown[];
};
const { items, ...attrs } = Astro.props;
---

<ul {...attrs}>
{
items.map((item, index, list) => (
<li set:html={Astro.slots.render("default", [item, index, list])} />
))
}
</ul>

<style>
ul {
margin: 0;
font-size: 0.5rem;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
const { name } = Astro.props;
---
<div class="item-wrapper">{name}</div>

<style>
.item-wrapper {
padding: 0.25rem;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
import Each from '../components/Each.astro';
import Item from '../components/Item.astro';
const items = ['one', 'two', 'three'];
---
<html>
<head>
<title>HMR Slots Render Test</title>
</head>
<body>
<div id="result">
<Each items={items}>
{(item) => <Item name={item} />}
</Each>
</div>
</body>
</html>
Loading
Loading