@rsc-kit/core 0.29.8 → 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
@@ -3023,7 +3023,7 @@ import { isSharedSection, sectionComponent } from ${JSON.stringify(join(packageD
3023
3023
  import { shareRender } from ${JSON.stringify(join(packageDir, "sharedRenders"))}
3024
3024
  import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameProvider"))}
3025
3025
  import { DefaultRouteError } from ${JSON.stringify(join(packageDir, "js/DefaultRouteError"))}
3026
- 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"))}
3027
3027
  import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
3028
3028
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
3029
3029
  import { noteRequestRead, notePageProps, urlOf } from ${JSON.stringify(join(packageDir, "request"))}
@@ -3942,6 +3942,22 @@ function buildElement(
3942
3942
  // Resolve route metadata into React elements. React 19 hoists <title>/<meta>
3943
3943
  // rendered anywhere in the tree into <head> — so the "vite way" for metadata is
3944
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
+
3945
3961
  async function renderTree(
3946
3962
  component: string,
3947
3963
  props: Record<string, unknown>,
@@ -3967,7 +3983,6 @@ async function renderTree(
3967
3983
  // The params promise travels too: during the pattern probe it never
3968
3984
  // settles, and a generateMetadata that reads it is then left out of the
3969
3985
  // shell rather than run against the placeholder - see resolveMetadata.
3970
- const md = await resolveMetadata(component, props, layouts, metadataParams)
3971
3986
  const head: unknown[] = []
3972
3987
 
3973
3988
  // Rendered into the tree rather than written into the app's layout: React
@@ -3981,15 +3996,6 @@ async function renderTree(
3981
3996
  // here, above the found images, because those are the ones most likely to
3982
3997
  // be relative: an opengraph-image.png in app/ is emitted as /_app/..., and a
3983
3998
  // share-card scraper needs the origin in front of it.
3984
- const base = md?.metadataBase ? String(md.metadataBase) : null
3985
- const absolute = (value: unknown): string => {
3986
- const text = String(value instanceof URL ? value.href : value)
3987
-
3988
- if (!base || /^[a-z][a-z0-9+.-]*:/i.test(text)) return text
3989
-
3990
- return new URL(text, base).href
3991
- }
3992
-
3993
3999
  // What Next wrote into every document without being asked, and a layout
3994
4000
  // copied from a Next app therefore never writes: the charset, and the
3995
4001
  // viewport - without which a phone lays the page out at desktop width.
@@ -4017,16 +4023,6 @@ async function renderTree(
4017
4023
  head.push(createElement('meta', { key: '__vcs', name: 'color-scheme', content: viewport.colorScheme }))
4018
4024
  }
4019
4025
 
4020
- for (const [i, found] of APP_HEAD.entries()) {
4021
- const props = { ...found.props }
4022
-
4023
- if (props.content && /^(og:image|twitter:image)$/.test(props.property ?? props.name ?? '')) {
4024
- props.content = absolute(props.content)
4025
- }
4026
-
4027
- head.push(createElement(found.tag, { key: '__a' + i, ...props }))
4028
- }
4029
-
4030
4026
  if (WEB_MANIFEST) {
4031
4027
  head.push(createElement('link', { key: '__mf', rel: 'manifest', href: WEB_MANIFEST.href }))
4032
4028
 
@@ -4037,215 +4033,264 @@ async function renderTree(
4037
4033
  }
4038
4034
  }
4039
4035
 
4040
- if (md) {
4041
- if (md.title != null) {
4042
- // The element is what a server render puts in <head>, and what a route
4043
- // with no runtime relies on entirely.
4044
- head.push(createElement('title', { key: '__t' }, String(md.title)))
4045
-
4046
- // And the effect is what keeps it right once pages are retained — see
4047
- // DocumentTitle. Only where there is a runtime to run it: a client
4048
- // component on a route that ships none is refused by the build.
4049
- if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
4050
- }
4051
- if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
4052
- // The name only, never the version - see the identify option.
4053
- if (manifest().build?.identify) head.push(createElement('meta', { key: '__g', name: 'generator', content: 'rsc-kit' }))
4054
- }
4055
-
4056
4036
  // Which build this document is, for the client to say on every navigation
4057
4037
  // - read from the document rather than learned from the first answer,
4058
4038
  // because a document a service worker served from its cache is the last
4059
4039
  // build's while the answers are this one's. An id, not a version number.
4060
4040
  if (bootstrap) head.push(createElement('meta', { key: '__v', name: 'rsc-kit:build', content: await buildId() }))
4061
4041
 
4062
- 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
4063
4052
 
4064
- // robots is a string, or the object Next takes: index and follow as
4065
- // their no- forms, the flags by name, the limits as name:value. googleBot
4066
- // is the same shape for the googlebot tag. The object used to fall
4067
- // through to the catch-all below as "[object Object]" - which no crawler
4068
- // reads, on the one page that asked not to be indexed.
4069
- const robotsContent = (value: unknown): string => {
4070
- if (typeof value !== 'object' || value === null) return String(value)
4053
+ return new URL(text, base).href
4054
+ }
4071
4055
 
4072
- const r = value as Record<string, unknown>
4073
- const parts: string[] = []
4056
+ for (const [i, found] of APP_HEAD.entries()) {
4057
+ const props = { ...found.props }
4074
4058
 
4075
- if (r.index != null) parts.push(r.index ? 'index' : 'noindex')
4076
- if (r.follow != null) parts.push(r.follow ? 'follow' : 'nofollow')
4077
- for (const flag of ['noarchive', 'nosnippet', 'noimageindex', 'nocache', 'notranslate', 'indexifembedded', 'nositelinkssearchbox']) {
4078
- if (r[flag]) parts.push(flag)
4079
- }
4080
- if (r.unavailable_after != null) parts.push('unavailable_after: ' + String(r.unavailable_after))
4081
- for (const limit of ['max-video-preview', 'max-image-preview', 'max-snippet']) {
4082
- 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)
4083
4061
  }
4084
4062
 
4085
- return parts.join(', ')
4063
+ head.push(createElement(found.tag, { key: '__a' + i, ...props }))
4086
4064
  }
4087
4065
 
4088
- if (md.robots != null) {
4089
- head.push(createElement('meta', { key: '__r', name: 'robots', content: robotsContent(md.robots) }))
4090
-
4091
- 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)))
4092
4071
 
4093
- 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' }))
4094
4080
  }
4095
4081
 
4096
- // og: and its relatives are PROPERTY, not name. Facebook's scraper - and
4097
- // Slack's, and LinkedIn's - reads only property=, so every og tag this
4098
- // used to emit with name= was invisible to the thing it existed for.
4099
- // Twitter reads name=, which is why the two are not one rule.
4100
- const isProperty = (k: string) => /^(og|article|profile|book|music|video|fb):/.test(k)
4101
- // The keys whose CONTENT is a url. Exact, plus the :url and :secure_url
4102
- // spellings - not og:image:width, which a looser match turned into
4103
- // https://site/1200 and no scraper would ever read.
4104
- const isUrlKey = (k: string) => /^(og:image|og:url|twitter:image)(:(secure_)?url)?$/.test(k)
4105
- const tag = (k: string, v: unknown) =>
4106
- createElement('meta', {
4107
- key: '__m_' + k + '_' + String(v).slice(0, 40),
4108
- [isProperty(k) ? 'property' : 'name']: k,
4109
- content: isUrlKey(k) ? absolute(v) : String(v),
4110
- })
4082
+ if (md) {
4111
4083
 
4112
- // One image, or several. Each becomes its own og:image plus the size and
4113
- // alt tags beside it, which is how a scraper is told which is which.
4114
- const images = (prefix: string, value: unknown): void => {
4115
- 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)
4116
4091
 
4117
- for (const item of list) {
4118
- if (item == null) continue
4092
+ const r = value as Record<string, unknown>
4093
+ const parts: string[] = []
4119
4094
 
4120
- if (typeof item === 'object' && !(item instanceof URL)) {
4121
- const image = item as { url: unknown; width?: number; height?: number; alt?: string; type?: string }
4122
-
4123
- head.push(tag(prefix, image.url))
4124
- if (image.width) head.push(tag(prefix + ':width', image.width))
4125
- if (image.height) head.push(tag(prefix + ':height', image.height))
4126
- if (image.alt) head.push(tag(prefix + ':alt', image.alt))
4127
- if (image.type) head.push(tag(prefix + ':type', image.type))
4128
- } else {
4129
- 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)
4130
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(', ')
4131
4106
  }
4132
- }
4133
4107
 
4134
- if (md.openGraph) {
4135
- 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) }))
4136
4110
 
4137
- if (og.title != null) head.push(tag('og:title', og.title))
4138
- if (og.description != null) head.push(tag('og:description', og.description))
4139
- if (og.url != null) head.push(tag('og:url', og.url))
4140
- if (og.siteName != null) head.push(tag('og:site_name', og.siteName))
4141
- if (og.type != null) head.push(tag('og:type', og.type))
4142
- if (og.locale != null) head.push(tag('og:locale', og.locale))
4143
- if (og.images != null) images('og:image', og.images)
4144
- }
4111
+ const bot = typeof md.robots === 'object' ? (md.robots as { googleBot?: unknown }).googleBot : null
4145
4112
 
4146
- if (md.twitter) {
4147
- const tw = md.twitter as Record<string, unknown>
4113
+ if (bot != null) head.push(createElement('meta', { key: '__rg', name: 'googlebot', content: robotsContent(bot) }))
4114
+ }
4148
4115
 
4149
- if (tw.card != null) head.push(tag('twitter:card', tw.card))
4150
- if (tw.title != null) head.push(tag('twitter:title', tw.title))
4151
- if (tw.description != null) head.push(tag('twitter:description', tw.description))
4152
- if (tw.site != null) head.push(tag('twitter:site', tw.site))
4153
- if (tw.creator != null) head.push(tag('twitter:creator', tw.creator))
4154
- if (tw.images != null) images('twitter:image', tw.images)
4155
- }
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
4156
4139
 
4157
- // icons is links, not meta. A string is one icon; an object names which
4158
- // rel each is for. The ones found in app/ are already in APP_HEAD above,
4159
- // so this is for an app that wants to say it explicitly.
4160
- const links = (rel: string, value: unknown): void => {
4161
- const list = Array.isArray(value) ? value : [value]
4162
-
4163
- for (const item of list) {
4164
- if (item == null) continue
4165
-
4166
- const icon = typeof item === 'object' && !(item instanceof URL)
4167
- ? (item as Record<string, unknown>)
4168
- : { url: item }
4169
-
4170
- head.push(
4171
- createElement('link', {
4172
- key: '__i_' + rel + '_' + String(icon.url).slice(0, 40),
4173
- rel: (icon.rel as string) ?? rel,
4174
- href: absolute(icon.url),
4175
- ...(icon.type ? { type: icon.type } : {}),
4176
- ...(icon.sizes ? { sizes: icon.sizes } : {}),
4177
- ...(icon.media ? { media: icon.media } : {}),
4178
- }),
4179
- )
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
+ }
4180
4152
  }
4181
- }
4182
4153
 
4183
- if (md.icons != null) {
4184
- const icons = md.icons as Record<string, unknown> | string | unknown[]
4154
+ if (md.openGraph) {
4155
+ const og = md.openGraph as Record<string, unknown>
4185
4156
 
4186
- if (typeof icons === 'string' || Array.isArray(icons) || icons instanceof URL) {
4187
- links('icon', icons)
4188
- } else {
4189
- if (icons.icon != null) links('icon', icons.icon)
4190
- if (icons.apple != null) links('apple-touch-icon', icons.apple)
4191
- if (icons.shortcut != null) links('shortcut icon', icons.shortcut)
4192
- 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)
4193
4164
  }
4194
- }
4195
4165
 
4196
- // How the app behaves added to an iPhone's home screen - Next's shape.
4197
- // Both capable names: Safari reads the apple- one, Chrome warns about it
4198
- // and reads the plain one, and Next switched between them in a minor.
4199
- if (md.appleWebApp != null && md.appleWebApp !== false) {
4200
- 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>
4201
4168
 
4202
- if (web.capable) {
4203
- head.push(tag('mobile-web-app-capable', 'yes'))
4204
- 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)
4205
4175
  }
4206
4176
 
4207
- if (web.title != null) head.push(tag('apple-mobile-web-app-title', web.title))
4208
- 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]
4209
4182
 
4210
- if (web.startupImage != null) {
4211
- const startup = Array.isArray(web.startupImage) ? web.startupImage : [web.startupImage]
4212
-
4213
- for (const item of startup) {
4183
+ for (const item of list) {
4214
4184
  if (item == null) continue
4215
4185
 
4216
- const image = typeof item === 'object' && !(item instanceof URL)
4186
+ const icon = typeof item === 'object' && !(item instanceof URL)
4217
4187
  ? (item as Record<string, unknown>)
4218
4188
  : { url: item }
4219
4189
 
4220
4190
  head.push(
4221
4191
  createElement('link', {
4222
- key: '__s_' + String(image.url).slice(0, 60) + '_' + String(image.media ?? ''),
4223
- rel: 'apple-touch-startup-image',
4224
- href: absolute(image.url),
4225
- ...(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 } : {}),
4226
4198
  }),
4227
4199
  )
4228
4200
  }
4229
4201
  }
4230
- }
4231
4202
 
4232
- // other is flattened in beside the named keys, because it is a place to put
4233
- // meta tags rather than a meta tag by that name. A key at the top level
4234
- // still renders - the type no longer invites one, but an app written
4235
- // against the old shape must not silently lose its tags.
4236
- const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'appleWebApp', 'other'])
4237
- const named = Object.entries(md).filter(([k]) => !structured.has(k))
4238
- const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
4239
-
4240
- for (const [k, v] of [...named, ...extra]) {
4241
- if (v == null) continue
4242
-
4243
- if (Array.isArray(v)) {
4244
- for (const item of v) head.push(tag(k, item))
4245
- } else {
4246
- 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
+ }
4247
4268
  }
4248
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
+ )
4249
4294
  }
4250
4295
 
4251
4296
  // Metadata elements are rendered INSIDE the document tree so React 19 hoists
@@ -4503,7 +4548,7 @@ export async function handleRscHtmlStream(
4503
4548
  const [forHtml, forPayload] = flight.tee()
4504
4549
  const rscPayloadPromise = new Response(forPayload).text()
4505
4550
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
4506
- const htmlStream = await ssr.handleSsr(forHtml, nonce, undefined, bootstrap, undefined, requestSearch())
4551
+ const htmlStream = await ssr.handleSsr(forHtml, nonce, undefined, bootstrap, undefined, requestSearch(), requestFromCrawler())
4507
4552
  return { htmlStream, rscPayloadPromise, clientChunks: {} }
4508
4553
  }
4509
4554
 
@@ -4550,7 +4595,7 @@ export async function handleRscFormPost(
4550
4595
  { onError: flightOnError },
4551
4596
  )
4552
4597
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
4553
- const htmlStream = await ssr.handleSsr(flight, nonce, undefined, bootstrap, formState, requestSearch())
4598
+ const htmlStream = await ssr.handleSsr(flight, nonce, undefined, bootstrap, formState, requestSearch(), requestFromCrawler())
4554
4599
 
4555
4600
  return { htmlStream }
4556
4601
  }
@@ -4620,11 +4665,10 @@ export async function handleRscResume(
4620
4665
  // rendered here for the url and hydrated against a payload that, like
4621
4666
  // the shell, does not know it. The browser fills it in.
4622
4667
  pageKey === '' ? null : pathname,
4623
- // And with params that never settled, its metadata left out where it
4624
- // read them; the same here, or the tree has a <title> the shell had
4625
- // not and the slots stop matching. The host writes the real title into
4626
- // the head as it serves the shell.
4627
- 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,
4628
4672
  ),
4629
4673
  { onError: flightOnError },
4630
4674
  )
@@ -4984,6 +5028,11 @@ export async function resolveMetadata(
4984
5028
  // The page's own params promise, when the caller has one. During the
4985
5029
  // pattern probe it never settles - see handleRscPprShell.
4986
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,
4987
5036
  ): Promise<Record<string, unknown> | null> {
4988
5037
  await instrumented()
4989
5038
 
@@ -5010,7 +5059,7 @@ export async function resolveMetadata(
5010
5059
  searchParams: pageSearchParams(),
5011
5060
  })
5012
5061
 
5013
- const settled = params
5062
+ const settled = params && !streamed
5014
5063
  ? await Promise.race([
5015
5064
  Promise.resolve(generated),
5016
5065
  new Promise<typeof METADATA_POSTPONED>((resolve) =>
@@ -5700,6 +5749,9 @@ export async function handleSsr(
5700
5749
  bootstrap = true,
5701
5750
  formState?: unknown,
5702
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,
5703
5755
  ): Promise<ReadableStream> {
5704
5756
  const root = withSearch(await createFromReadableStream(rscStream), search)
5705
5757
 
@@ -5725,6 +5777,8 @@ export async function handleSsr(
5725
5777
  ...(formState !== undefined ? { formState: formState as any } : {}),
5726
5778
  })
5727
5779
 
5780
+ if (whole) await html.allReady
5781
+
5728
5782
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
5729
5783
  }
5730
5784