@rsc-kit/core 0.29.7 → 0.29.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vite.js CHANGED
@@ -351,7 +351,11 @@ function aliasEntries() {
351
351
  function resolvePaths(options) {
352
352
  projectRoot = resolve(options.projectRoot || process.env.RSC_PROJECT_ROOT || process.cwd());
353
353
  sourceDir = resolve(options.sourceDir || process.env.RSC_SOURCE_DIR || join(projectRoot, "src"));
354
- outDir = resolve(options.outDir || process.env.RSC_OUT_DIR || join(projectRoot, ".rsc"));
354
+ outDir = resolve(
355
+ // .rsc-kit/: the one folder rsc-kit generates into - the types beside
356
+ // the route table, the build report and the server entries - rather
357
+ // than a second one beside it.
358
+ options.outDir || process.env.RSC_OUT_DIR || join(projectRoot, ".rsc-kit"));
355
359
  appDir = join(sourceDir, "app");
356
360
  // Generated entries live under the (in-project) out dir so module resolution
357
361
  // can walk up to the project's node_modules (@vitejs/plugin-rsc, react, ...).
@@ -3019,7 +3023,7 @@ import { isSharedSection, sectionComponent } from ${JSON.stringify(join(packageD
3019
3023
  import { shareRender } from ${JSON.stringify(join(packageDir, "sharedRenders"))}
3020
3024
  import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameProvider"))}
3021
3025
  import { DefaultRouteError } from ${JSON.stringify(join(packageDir, "js/DefaultRouteError"))}
3022
- import { requestSearch, searchParams as requestSearchParams, withUrl } from ${JSON.stringify(join(packageDir, "request"))}
3026
+ import { requestFromCrawler, requestSearch, searchParams as requestSearchParams, withUrl } from ${JSON.stringify(join(packageDir, "request"))}
3023
3027
  import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
3024
3028
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
3025
3029
  import { noteRequestRead, notePageProps, urlOf } from ${JSON.stringify(join(packageDir, "request"))}
@@ -3938,6 +3942,22 @@ function buildElement(
3938
3942
  // Resolve route metadata into React elements. React 19 hoists <title>/<meta>
3939
3943
  // rendered anywhere in the tree into <head> — so the "vite way" for metadata is
3940
3944
  // to render it as elements, rather than a backend injecting a <head> string.
3945
+ /** Whether any file of this route generates its metadata - then it streams. */
3946
+ function metadataStreams(component: string, layouts: LayoutEntry[]): boolean {
3947
+ return [component, ...layouts.map((l) => l.component)].some((c) => Boolean(metadataMap[c]?.generate))
3948
+ }
3949
+
3950
+ /** The page's generated metadata, rendered when it resolves: its own boundary, so nothing waits for it. */
3951
+ async function StreamedMetadata({
3952
+ load,
3953
+ render,
3954
+ }: {
3955
+ load: () => Promise<Record<string, unknown> | null>
3956
+ render: (md: Record<string, unknown> | null) => unknown[]
3957
+ }) {
3958
+ return render(await load()) as never
3959
+ }
3960
+
3941
3961
  async function renderTree(
3942
3962
  component: string,
3943
3963
  props: Record<string, unknown>,
@@ -3963,7 +3983,6 @@ async function renderTree(
3963
3983
  // The params promise travels too: during the pattern probe it never
3964
3984
  // settles, and a generateMetadata that reads it is then left out of the
3965
3985
  // shell rather than run against the placeholder - see resolveMetadata.
3966
- const md = await resolveMetadata(component, props, layouts, metadataParams)
3967
3986
  const head: unknown[] = []
3968
3987
 
3969
3988
  // Rendered into the tree rather than written into the app's layout: React
@@ -3977,15 +3996,6 @@ async function renderTree(
3977
3996
  // here, above the found images, because those are the ones most likely to
3978
3997
  // be relative: an opengraph-image.png in app/ is emitted as /_app/..., and a
3979
3998
  // share-card scraper needs the origin in front of it.
3980
- const base = md?.metadataBase ? String(md.metadataBase) : null
3981
- const absolute = (value: unknown): string => {
3982
- const text = String(value instanceof URL ? value.href : value)
3983
-
3984
- if (!base || /^[a-z][a-z0-9+.-]*:/i.test(text)) return text
3985
-
3986
- return new URL(text, base).href
3987
- }
3988
-
3989
3999
  // What Next wrote into every document without being asked, and a layout
3990
4000
  // copied from a Next app therefore never writes: the charset, and the
3991
4001
  // viewport - without which a phone lays the page out at desktop width.
@@ -4013,16 +4023,6 @@ async function renderTree(
4013
4023
  head.push(createElement('meta', { key: '__vcs', name: 'color-scheme', content: viewport.colorScheme }))
4014
4024
  }
4015
4025
 
4016
- for (const [i, found] of APP_HEAD.entries()) {
4017
- const props = { ...found.props }
4018
-
4019
- if (props.content && /^(og:image|twitter:image)$/.test(props.property ?? props.name ?? '')) {
4020
- props.content = absolute(props.content)
4021
- }
4022
-
4023
- head.push(createElement(found.tag, { key: '__a' + i, ...props }))
4024
- }
4025
-
4026
4026
  if (WEB_MANIFEST) {
4027
4027
  head.push(createElement('link', { key: '__mf', rel: 'manifest', href: WEB_MANIFEST.href }))
4028
4028
 
@@ -4033,215 +4033,264 @@ async function renderTree(
4033
4033
  }
4034
4034
  }
4035
4035
 
4036
- if (md) {
4037
- if (md.title != null) {
4038
- // The element is what a server render puts in <head>, and what a route
4039
- // with no runtime relies on entirely.
4040
- head.push(createElement('title', { key: '__t' }, String(md.title)))
4041
-
4042
- // And the effect is what keeps it right once pages are retained — see
4043
- // DocumentTitle. Only where there is a runtime to run it: a client
4044
- // component on a route that ships none is refused by the build.
4045
- if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
4046
- }
4047
- if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
4048
- // The name only, never the version - see the identify option.
4049
- if (manifest().build?.identify) head.push(createElement('meta', { key: '__g', name: 'generator', content: 'rsc-kit' }))
4050
- }
4051
-
4052
4036
  // Which build this document is, for the client to say on every navigation
4053
4037
  // - read from the document rather than learned from the first answer,
4054
4038
  // because a document a service worker served from its cache is the last
4055
4039
  // build's while the answers are this one's. An id, not a version number.
4056
4040
  if (bootstrap) head.push(createElement('meta', { key: '__v', name: 'rsc-kit:build', content: await buildId() }))
4057
4041
 
4058
- if (md) {
4042
+ // What the page's metadata adds - its title, description, share tags,
4043
+ // robots, icons - and the found icons and share images, made absolute
4044
+ // against its metadataBase.
4045
+ const metadataHead = (md: Record<string, unknown> | null): unknown[] => {
4046
+ const head: unknown[] = []
4047
+ const base = md?.metadataBase ? String(md.metadataBase) : null
4048
+ const absolute = (value: unknown): string => {
4049
+ const text = String(value instanceof URL ? value.href : value)
4050
+
4051
+ if (!base || /^[a-z][a-z0-9+.-]*:/i.test(text)) return text
4059
4052
 
4060
- // robots is a string, or the object Next takes: index and follow as
4061
- // their no- forms, the flags by name, the limits as name:value. googleBot
4062
- // is the same shape for the googlebot tag. The object used to fall
4063
- // through to the catch-all below as "[object Object]" - which no crawler
4064
- // reads, on the one page that asked not to be indexed.
4065
- const robotsContent = (value: unknown): string => {
4066
- if (typeof value !== 'object' || value === null) return String(value)
4053
+ return new URL(text, base).href
4054
+ }
4067
4055
 
4068
- const r = value as Record<string, unknown>
4069
- const parts: string[] = []
4056
+ for (const [i, found] of APP_HEAD.entries()) {
4057
+ const props = { ...found.props }
4070
4058
 
4071
- if (r.index != null) parts.push(r.index ? 'index' : 'noindex')
4072
- if (r.follow != null) parts.push(r.follow ? 'follow' : 'nofollow')
4073
- for (const flag of ['noarchive', 'nosnippet', 'noimageindex', 'nocache', 'notranslate', 'indexifembedded', 'nositelinkssearchbox']) {
4074
- if (r[flag]) parts.push(flag)
4075
- }
4076
- if (r.unavailable_after != null) parts.push('unavailable_after: ' + String(r.unavailable_after))
4077
- for (const limit of ['max-video-preview', 'max-image-preview', 'max-snippet']) {
4078
- if (r[limit] != null) parts.push(limit + ':' + String(r[limit]))
4059
+ if (props.content && /^(og:image|twitter:image)$/.test(props.property ?? props.name ?? '')) {
4060
+ props.content = absolute(props.content)
4079
4061
  }
4080
4062
 
4081
- return parts.join(', ')
4063
+ head.push(createElement(found.tag, { key: '__a' + i, ...props }))
4082
4064
  }
4083
4065
 
4084
- if (md.robots != null) {
4085
- head.push(createElement('meta', { key: '__r', name: 'robots', content: robotsContent(md.robots) }))
4086
-
4087
- const bot = typeof md.robots === 'object' ? (md.robots as { googleBot?: unknown }).googleBot : null
4066
+ if (md) {
4067
+ if (md.title != null) {
4068
+ // The element is what a server render puts in <head>, and what a route
4069
+ // with no runtime relies on entirely.
4070
+ head.push(createElement('title', { key: '__t' }, String(md.title)))
4088
4071
 
4089
- if (bot != null) head.push(createElement('meta', { key: '__rg', name: 'googlebot', content: robotsContent(bot) }))
4072
+ // And the effect is what keeps it right once pages are retained — see
4073
+ // DocumentTitle. Only where there is a runtime to run it: a client
4074
+ // component on a route that ships none is refused by the build.
4075
+ if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
4076
+ }
4077
+ if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
4078
+ // The name only, never the version - see the identify option.
4079
+ if (manifest().build?.identify) head.push(createElement('meta', { key: '__g', name: 'generator', content: 'rsc-kit' }))
4090
4080
  }
4091
4081
 
4092
- // og: and its relatives are PROPERTY, not name. Facebook's scraper - and
4093
- // Slack's, and LinkedIn's - reads only property=, so every og tag this
4094
- // used to emit with name= was invisible to the thing it existed for.
4095
- // Twitter reads name=, which is why the two are not one rule.
4096
- const isProperty = (k: string) => /^(og|article|profile|book|music|video|fb):/.test(k)
4097
- // The keys whose CONTENT is a url. Exact, plus the :url and :secure_url
4098
- // spellings - not og:image:width, which a looser match turned into
4099
- // https://site/1200 and no scraper would ever read.
4100
- const isUrlKey = (k: string) => /^(og:image|og:url|twitter:image)(:(secure_)?url)?$/.test(k)
4101
- const tag = (k: string, v: unknown) =>
4102
- createElement('meta', {
4103
- key: '__m_' + k + '_' + String(v).slice(0, 40),
4104
- [isProperty(k) ? 'property' : 'name']: k,
4105
- content: isUrlKey(k) ? absolute(v) : String(v),
4106
- })
4082
+ if (md) {
4107
4083
 
4108
- // One image, or several. Each becomes its own og:image plus the size and
4109
- // alt tags beside it, which is how a scraper is told which is which.
4110
- const images = (prefix: string, value: unknown): void => {
4111
- const list = Array.isArray(value) ? value : [value]
4084
+ // robots is a string, or the object Next takes: index and follow as
4085
+ // their no- forms, the flags by name, the limits as name:value. googleBot
4086
+ // is the same shape for the googlebot tag. The object used to fall
4087
+ // through to the catch-all below as "[object Object]" - which no crawler
4088
+ // reads, on the one page that asked not to be indexed.
4089
+ const robotsContent = (value: unknown): string => {
4090
+ if (typeof value !== 'object' || value === null) return String(value)
4112
4091
 
4113
- for (const item of list) {
4114
- if (item == null) continue
4092
+ const r = value as Record<string, unknown>
4093
+ const parts: string[] = []
4115
4094
 
4116
- if (typeof item === 'object' && !(item instanceof URL)) {
4117
- const image = item as { url: unknown; width?: number; height?: number; alt?: string; type?: string }
4118
-
4119
- head.push(tag(prefix, image.url))
4120
- if (image.width) head.push(tag(prefix + ':width', image.width))
4121
- if (image.height) head.push(tag(prefix + ':height', image.height))
4122
- if (image.alt) head.push(tag(prefix + ':alt', image.alt))
4123
- if (image.type) head.push(tag(prefix + ':type', image.type))
4124
- } else {
4125
- head.push(tag(prefix, item))
4095
+ if (r.index != null) parts.push(r.index ? 'index' : 'noindex')
4096
+ if (r.follow != null) parts.push(r.follow ? 'follow' : 'nofollow')
4097
+ for (const flag of ['noarchive', 'nosnippet', 'noimageindex', 'nocache', 'notranslate', 'indexifembedded', 'nositelinkssearchbox']) {
4098
+ if (r[flag]) parts.push(flag)
4126
4099
  }
4100
+ if (r.unavailable_after != null) parts.push('unavailable_after: ' + String(r.unavailable_after))
4101
+ for (const limit of ['max-video-preview', 'max-image-preview', 'max-snippet']) {
4102
+ if (r[limit] != null) parts.push(limit + ':' + String(r[limit]))
4103
+ }
4104
+
4105
+ return parts.join(', ')
4127
4106
  }
4128
- }
4129
4107
 
4130
- if (md.openGraph) {
4131
- const og = md.openGraph as Record<string, unknown>
4108
+ if (md.robots != null) {
4109
+ head.push(createElement('meta', { key: '__r', name: 'robots', content: robotsContent(md.robots) }))
4132
4110
 
4133
- if (og.title != null) head.push(tag('og:title', og.title))
4134
- if (og.description != null) head.push(tag('og:description', og.description))
4135
- if (og.url != null) head.push(tag('og:url', og.url))
4136
- if (og.siteName != null) head.push(tag('og:site_name', og.siteName))
4137
- if (og.type != null) head.push(tag('og:type', og.type))
4138
- if (og.locale != null) head.push(tag('og:locale', og.locale))
4139
- if (og.images != null) images('og:image', og.images)
4140
- }
4111
+ const bot = typeof md.robots === 'object' ? (md.robots as { googleBot?: unknown }).googleBot : null
4141
4112
 
4142
- if (md.twitter) {
4143
- const tw = md.twitter as Record<string, unknown>
4113
+ if (bot != null) head.push(createElement('meta', { key: '__rg', name: 'googlebot', content: robotsContent(bot) }))
4114
+ }
4144
4115
 
4145
- if (tw.card != null) head.push(tag('twitter:card', tw.card))
4146
- if (tw.title != null) head.push(tag('twitter:title', tw.title))
4147
- if (tw.description != null) head.push(tag('twitter:description', tw.description))
4148
- if (tw.site != null) head.push(tag('twitter:site', tw.site))
4149
- if (tw.creator != null) head.push(tag('twitter:creator', tw.creator))
4150
- if (tw.images != null) images('twitter:image', tw.images)
4151
- }
4116
+ // og: and its relatives are PROPERTY, not name. Facebook's scraper - and
4117
+ // Slack's, and LinkedIn's - reads only property=, so every og tag this
4118
+ // used to emit with name= was invisible to the thing it existed for.
4119
+ // Twitter reads name=, which is why the two are not one rule.
4120
+ const isProperty = (k: string) => /^(og|article|profile|book|music|video|fb):/.test(k)
4121
+ // The keys whose CONTENT is a url. Exact, plus the :url and :secure_url
4122
+ // spellings - not og:image:width, which a looser match turned into
4123
+ // https://site/1200 and no scraper would ever read.
4124
+ const isUrlKey = (k: string) => /^(og:image|og:url|twitter:image)(:(secure_)?url)?$/.test(k)
4125
+ const tag = (k: string, v: unknown) =>
4126
+ createElement('meta', {
4127
+ key: '__m_' + k + '_' + String(v).slice(0, 40),
4128
+ [isProperty(k) ? 'property' : 'name']: k,
4129
+ content: isUrlKey(k) ? absolute(v) : String(v),
4130
+ })
4131
+
4132
+ // One image, or several. Each becomes its own og:image plus the size and
4133
+ // alt tags beside it, which is how a scraper is told which is which.
4134
+ const images = (prefix: string, value: unknown): void => {
4135
+ const list = Array.isArray(value) ? value : [value]
4136
+
4137
+ for (const item of list) {
4138
+ if (item == null) continue
4152
4139
 
4153
- // icons is links, not meta. A string is one icon; an object names which
4154
- // rel each is for. The ones found in app/ are already in APP_HEAD above,
4155
- // so this is for an app that wants to say it explicitly.
4156
- const links = (rel: string, value: unknown): void => {
4157
- const list = Array.isArray(value) ? value : [value]
4158
-
4159
- for (const item of list) {
4160
- if (item == null) continue
4161
-
4162
- const icon = typeof item === 'object' && !(item instanceof URL)
4163
- ? (item as Record<string, unknown>)
4164
- : { url: item }
4165
-
4166
- head.push(
4167
- createElement('link', {
4168
- key: '__i_' + rel + '_' + String(icon.url).slice(0, 40),
4169
- rel: (icon.rel as string) ?? rel,
4170
- href: absolute(icon.url),
4171
- ...(icon.type ? { type: icon.type } : {}),
4172
- ...(icon.sizes ? { sizes: icon.sizes } : {}),
4173
- ...(icon.media ? { media: icon.media } : {}),
4174
- }),
4175
- )
4140
+ if (typeof item === 'object' && !(item instanceof URL)) {
4141
+ const image = item as { url: unknown; width?: number; height?: number; alt?: string; type?: string }
4142
+
4143
+ head.push(tag(prefix, image.url))
4144
+ if (image.width) head.push(tag(prefix + ':width', image.width))
4145
+ if (image.height) head.push(tag(prefix + ':height', image.height))
4146
+ if (image.alt) head.push(tag(prefix + ':alt', image.alt))
4147
+ if (image.type) head.push(tag(prefix + ':type', image.type))
4148
+ } else {
4149
+ head.push(tag(prefix, item))
4150
+ }
4151
+ }
4176
4152
  }
4177
- }
4178
4153
 
4179
- if (md.icons != null) {
4180
- const icons = md.icons as Record<string, unknown> | string | unknown[]
4154
+ if (md.openGraph) {
4155
+ const og = md.openGraph as Record<string, unknown>
4181
4156
 
4182
- if (typeof icons === 'string' || Array.isArray(icons) || icons instanceof URL) {
4183
- links('icon', icons)
4184
- } else {
4185
- if (icons.icon != null) links('icon', icons.icon)
4186
- if (icons.apple != null) links('apple-touch-icon', icons.apple)
4187
- if (icons.shortcut != null) links('shortcut icon', icons.shortcut)
4188
- if (icons.other != null) links('icon', icons.other)
4157
+ if (og.title != null) head.push(tag('og:title', og.title))
4158
+ if (og.description != null) head.push(tag('og:description', og.description))
4159
+ if (og.url != null) head.push(tag('og:url', og.url))
4160
+ if (og.siteName != null) head.push(tag('og:site_name', og.siteName))
4161
+ if (og.type != null) head.push(tag('og:type', og.type))
4162
+ if (og.locale != null) head.push(tag('og:locale', og.locale))
4163
+ if (og.images != null) images('og:image', og.images)
4189
4164
  }
4190
- }
4191
4165
 
4192
- // How the app behaves added to an iPhone's home screen - Next's shape.
4193
- // Both capable names: Safari reads the apple- one, Chrome warns about it
4194
- // and reads the plain one, and Next switched between them in a minor.
4195
- if (md.appleWebApp != null && md.appleWebApp !== false) {
4196
- const web = (md.appleWebApp === true ? { capable: true } : md.appleWebApp) as Record<string, unknown>
4166
+ if (md.twitter) {
4167
+ const tw = md.twitter as Record<string, unknown>
4197
4168
 
4198
- if (web.capable) {
4199
- head.push(tag('mobile-web-app-capable', 'yes'))
4200
- head.push(tag('apple-mobile-web-app-capable', 'yes'))
4169
+ if (tw.card != null) head.push(tag('twitter:card', tw.card))
4170
+ if (tw.title != null) head.push(tag('twitter:title', tw.title))
4171
+ if (tw.description != null) head.push(tag('twitter:description', tw.description))
4172
+ if (tw.site != null) head.push(tag('twitter:site', tw.site))
4173
+ if (tw.creator != null) head.push(tag('twitter:creator', tw.creator))
4174
+ if (tw.images != null) images('twitter:image', tw.images)
4201
4175
  }
4202
4176
 
4203
- if (web.title != null) head.push(tag('apple-mobile-web-app-title', web.title))
4204
- if (web.statusBarStyle != null) head.push(tag('apple-mobile-web-app-status-bar-style', web.statusBarStyle))
4177
+ // icons is links, not meta. A string is one icon; an object names which
4178
+ // rel each is for. The ones found in app/ are already in APP_HEAD above,
4179
+ // so this is for an app that wants to say it explicitly.
4180
+ const links = (rel: string, value: unknown): void => {
4181
+ const list = Array.isArray(value) ? value : [value]
4205
4182
 
4206
- if (web.startupImage != null) {
4207
- const startup = Array.isArray(web.startupImage) ? web.startupImage : [web.startupImage]
4208
-
4209
- for (const item of startup) {
4183
+ for (const item of list) {
4210
4184
  if (item == null) continue
4211
4185
 
4212
- const image = typeof item === 'object' && !(item instanceof URL)
4186
+ const icon = typeof item === 'object' && !(item instanceof URL)
4213
4187
  ? (item as Record<string, unknown>)
4214
4188
  : { url: item }
4215
4189
 
4216
4190
  head.push(
4217
4191
  createElement('link', {
4218
- key: '__s_' + String(image.url).slice(0, 60) + '_' + String(image.media ?? ''),
4219
- rel: 'apple-touch-startup-image',
4220
- href: absolute(image.url),
4221
- ...(image.media ? { media: image.media } : {}),
4192
+ key: '__i_' + rel + '_' + String(icon.url).slice(0, 40),
4193
+ rel: (icon.rel as string) ?? rel,
4194
+ href: absolute(icon.url),
4195
+ ...(icon.type ? { type: icon.type } : {}),
4196
+ ...(icon.sizes ? { sizes: icon.sizes } : {}),
4197
+ ...(icon.media ? { media: icon.media } : {}),
4222
4198
  }),
4223
4199
  )
4224
4200
  }
4225
4201
  }
4226
- }
4227
4202
 
4228
- // other is flattened in beside the named keys, because it is a place to put
4229
- // meta tags rather than a meta tag by that name. A key at the top level
4230
- // still renders - the type no longer invites one, but an app written
4231
- // against the old shape must not silently lose its tags.
4232
- const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'appleWebApp', 'other'])
4233
- const named = Object.entries(md).filter(([k]) => !structured.has(k))
4234
- const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
4235
-
4236
- for (const [k, v] of [...named, ...extra]) {
4237
- if (v == null) continue
4238
-
4239
- if (Array.isArray(v)) {
4240
- for (const item of v) head.push(tag(k, item))
4241
- } else {
4242
- head.push(tag(k, v))
4203
+ if (md.icons != null) {
4204
+ const icons = md.icons as Record<string, unknown> | string | unknown[]
4205
+
4206
+ if (typeof icons === 'string' || Array.isArray(icons) || icons instanceof URL) {
4207
+ links('icon', icons)
4208
+ } else {
4209
+ if (icons.icon != null) links('icon', icons.icon)
4210
+ if (icons.apple != null) links('apple-touch-icon', icons.apple)
4211
+ if (icons.shortcut != null) links('shortcut icon', icons.shortcut)
4212
+ if (icons.other != null) links('icon', icons.other)
4213
+ }
4214
+ }
4215
+
4216
+ // How the app behaves added to an iPhone's home screen - Next's shape.
4217
+ // Both capable names: Safari reads the apple- one, Chrome warns about it
4218
+ // and reads the plain one, and Next switched between them in a minor.
4219
+ if (md.appleWebApp != null && md.appleWebApp !== false) {
4220
+ const web = (md.appleWebApp === true ? { capable: true } : md.appleWebApp) as Record<string, unknown>
4221
+
4222
+ if (web.capable) {
4223
+ head.push(tag('mobile-web-app-capable', 'yes'))
4224
+ head.push(tag('apple-mobile-web-app-capable', 'yes'))
4225
+ }
4226
+
4227
+ if (web.title != null) head.push(tag('apple-mobile-web-app-title', web.title))
4228
+ if (web.statusBarStyle != null) head.push(tag('apple-mobile-web-app-status-bar-style', web.statusBarStyle))
4229
+
4230
+ if (web.startupImage != null) {
4231
+ const startup = Array.isArray(web.startupImage) ? web.startupImage : [web.startupImage]
4232
+
4233
+ for (const item of startup) {
4234
+ if (item == null) continue
4235
+
4236
+ const image = typeof item === 'object' && !(item instanceof URL)
4237
+ ? (item as Record<string, unknown>)
4238
+ : { url: item }
4239
+
4240
+ head.push(
4241
+ createElement('link', {
4242
+ key: '__s_' + String(image.url).slice(0, 60) + '_' + String(image.media ?? ''),
4243
+ rel: 'apple-touch-startup-image',
4244
+ href: absolute(image.url),
4245
+ ...(image.media ? { media: image.media } : {}),
4246
+ }),
4247
+ )
4248
+ }
4249
+ }
4250
+ }
4251
+
4252
+ // other is flattened in beside the named keys, because it is a place to put
4253
+ // meta tags rather than a meta tag by that name. A key at the top level
4254
+ // still renders - the type no longer invites one, but an app written
4255
+ // against the old shape must not silently lose its tags.
4256
+ const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'appleWebApp', 'other'])
4257
+ const named = Object.entries(md).filter(([k]) => !structured.has(k))
4258
+ const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
4259
+
4260
+ for (const [k, v] of [...named, ...extra]) {
4261
+ if (v == null) continue
4262
+
4263
+ if (Array.isArray(v)) {
4264
+ for (const item of v) head.push(tag(k, item))
4265
+ } else {
4266
+ head.push(tag(k, v))
4267
+ }
4243
4268
  }
4244
4269
  }
4270
+
4271
+
4272
+ return head
4273
+ }
4274
+
4275
+ // Static metadata - export const metadata on every file of the route -
4276
+ // costs nothing to resolve, and is in the shell. A generateMetadata is
4277
+ // data: it streams, under its own boundary, like any other read, so the
4278
+ // page paints at once and the title arrives when it is ready. Never held
4279
+ // up for it. A crawler, which needs the title and the status in what it
4280
+ // reads first, is answered once the whole document is ready - see the host.
4281
+ if (!metadataStreams(component, layouts)) {
4282
+ head.push(...metadataHead(await resolveMetadata(component, props, layouts, metadataParams)))
4283
+ } else {
4284
+ head.push(
4285
+ createElement(
4286
+ Suspense,
4287
+ { key: '__md', fallback: null },
4288
+ createElement(StreamedMetadata as never, {
4289
+ load: () => resolveMetadata(component, props, layouts, metadataParams, true),
4290
+ render: metadataHead,
4291
+ }),
4292
+ ),
4293
+ )
4245
4294
  }
4246
4295
 
4247
4296
  // Metadata elements are rendered INSIDE the document tree so React 19 hoists
@@ -4499,7 +4548,7 @@ export async function handleRscHtmlStream(
4499
4548
  const [forHtml, forPayload] = flight.tee()
4500
4549
  const rscPayloadPromise = new Response(forPayload).text()
4501
4550
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
4502
- const htmlStream = await ssr.handleSsr(forHtml, nonce, undefined, bootstrap, undefined, requestSearch())
4551
+ const htmlStream = await ssr.handleSsr(forHtml, nonce, undefined, bootstrap, undefined, requestSearch(), requestFromCrawler())
4503
4552
  return { htmlStream, rscPayloadPromise, clientChunks: {} }
4504
4553
  }
4505
4554
 
@@ -4546,7 +4595,7 @@ export async function handleRscFormPost(
4546
4595
  { onError: flightOnError },
4547
4596
  )
4548
4597
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
4549
- const htmlStream = await ssr.handleSsr(flight, nonce, undefined, bootstrap, formState, requestSearch())
4598
+ const htmlStream = await ssr.handleSsr(flight, nonce, undefined, bootstrap, formState, requestSearch(), requestFromCrawler())
4550
4599
 
4551
4600
  return { htmlStream }
4552
4601
  }
@@ -4616,11 +4665,10 @@ export async function handleRscResume(
4616
4665
  // rendered here for the url and hydrated against a payload that, like
4617
4666
  // the shell, does not know it. The browser fills it in.
4618
4667
  pageKey === '' ? null : pathname,
4619
- // And with params that never settled, its metadata left out where it
4620
- // read them; the same here, or the tree has a <title> the shell had
4621
- // not and the slots stop matching. The host writes the real title into
4622
- // the head as it serves the shell.
4623
- pageKey === '' ? new Promise<Record<string, unknown>>(() => {}) : undefined,
4668
+ // The metadata is the real params' too. A generateMetadata streams
4669
+ // under its own boundary, which the pattern probe left as a hole; this
4670
+ // is what fills it, with the page's real title.
4671
+ undefined,
4624
4672
  ),
4625
4673
  { onError: flightOnError },
4626
4674
  )
@@ -4980,6 +5028,11 @@ export async function resolveMetadata(
4980
5028
  // The page's own params promise, when the caller has one. During the
4981
5029
  // pattern probe it never settles - see handleRscPprShell.
4982
5030
  params?: Promise<Record<string, unknown>>,
5031
+ // Rendered under its own boundary: no grace to race. A generateMetadata
5032
+ // waiting on params that never settle - the build's pattern probe - is a
5033
+ // hole like any other read, filled per request with the real title,
5034
+ // instead of an empty one frozen into the shell.
5035
+ streamed = false,
4983
5036
  ): Promise<Record<string, unknown> | null> {
4984
5037
  await instrumented()
4985
5038
 
@@ -5006,7 +5059,7 @@ export async function resolveMetadata(
5006
5059
  searchParams: pageSearchParams(),
5007
5060
  })
5008
5061
 
5009
- const settled = params
5062
+ const settled = params && !streamed
5010
5063
  ? await Promise.race([
5011
5064
  Promise.resolve(generated),
5012
5065
  new Promise<typeof METADATA_POSTPONED>((resolve) =>
@@ -5696,6 +5749,9 @@ export async function handleSsr(
5696
5749
  bootstrap = true,
5697
5750
  formState?: unknown,
5698
5751
  search?: string | null,
5752
+ // A crawler's request: answered once everything has rendered - see
5753
+ // crawler.ts - so the host reads the page's final word on its status.
5754
+ whole = false,
5699
5755
  ): Promise<ReadableStream> {
5700
5756
  const root = withSearch(await createFromReadableStream(rscStream), search)
5701
5757
 
@@ -5721,6 +5777,8 @@ export async function handleSsr(
5721
5777
  ...(formState !== undefined ? { formState: formState as any } : {}),
5722
5778
  })
5723
5779
 
5780
+ if (whole) await html.allReady
5781
+
5724
5782
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
5725
5783
  }
5726
5784
 
@@ -6746,7 +6804,14 @@ export function rscKit(options = {}) {
6746
6804
  return [
6747
6805
  join(projectRoot, HOST_FILE),
6748
6806
  join(sourceDir, HOST_ACTIONS_MODULE),
6749
- ...(existsSync(typesDir) ? readdirSync(typesDir).map((name) => join(typesDir, name)) : []),
6807
+ // The type declarations only: the folder also holds the build's
6808
+ // own output - the route table, its report, the server entries -
6809
+ // which nobody commits and --check has no business comparing.
6810
+ ...(existsSync(typesDir)
6811
+ ? readdirSync(typesDir)
6812
+ .filter((name) => name.endsWith(".d.ts"))
6813
+ .map((name) => join(typesDir, name))
6814
+ : []),
6750
6815
  ];
6751
6816
  },
6752
6817
  },