@rsc-kit/core 0.29.8 → 0.29.10

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,9 +3023,10 @@ 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
+ import { bootToken } from ${JSON.stringify(join(packageDir, "bootPayloads"))}
3029
3030
  import { noteRequestRead, notePageProps, urlOf } from ${JSON.stringify(join(packageDir, "request"))}
3030
3031
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
3031
3032
  import { cancelledByConsumer } from ${JSON.stringify(join(packageDir, "js/fallbackReport"))}
@@ -3942,6 +3943,22 @@ function buildElement(
3942
3943
  // Resolve route metadata into React elements. React 19 hoists <title>/<meta>
3943
3944
  // rendered anywhere in the tree into <head> — so the "vite way" for metadata is
3944
3945
  // to render it as elements, rather than a backend injecting a <head> string.
3946
+ /** Whether any file of this route generates its metadata - then it streams. */
3947
+ function metadataStreams(component: string, layouts: LayoutEntry[]): boolean {
3948
+ return [component, ...layouts.map((l) => l.component)].some((c) => Boolean(metadataMap[c]?.generate))
3949
+ }
3950
+
3951
+ /** The page's generated metadata, rendered when it resolves: its own boundary, so nothing waits for it. */
3952
+ async function StreamedMetadata({
3953
+ load,
3954
+ render,
3955
+ }: {
3956
+ load: () => Promise<Record<string, unknown> | null>
3957
+ render: (md: Record<string, unknown> | null) => unknown[]
3958
+ }) {
3959
+ return render(await load()) as never
3960
+ }
3961
+
3945
3962
  async function renderTree(
3946
3963
  component: string,
3947
3964
  props: Record<string, unknown>,
@@ -3967,7 +3984,6 @@ async function renderTree(
3967
3984
  // The params promise travels too: during the pattern probe it never
3968
3985
  // settles, and a generateMetadata that reads it is then left out of the
3969
3986
  // shell rather than run against the placeholder - see resolveMetadata.
3970
- const md = await resolveMetadata(component, props, layouts, metadataParams)
3971
3987
  const head: unknown[] = []
3972
3988
 
3973
3989
  // Rendered into the tree rather than written into the app's layout: React
@@ -3981,15 +3997,6 @@ async function renderTree(
3981
3997
  // here, above the found images, because those are the ones most likely to
3982
3998
  // be relative: an opengraph-image.png in app/ is emitted as /_app/..., and a
3983
3999
  // 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
4000
  // What Next wrote into every document without being asked, and a layout
3994
4001
  // copied from a Next app therefore never writes: the charset, and the
3995
4002
  // viewport - without which a phone lays the page out at desktop width.
@@ -4017,16 +4024,6 @@ async function renderTree(
4017
4024
  head.push(createElement('meta', { key: '__vcs', name: 'color-scheme', content: viewport.colorScheme }))
4018
4025
  }
4019
4026
 
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
4027
  if (WEB_MANIFEST) {
4031
4028
  head.push(createElement('link', { key: '__mf', rel: 'manifest', href: WEB_MANIFEST.href }))
4032
4029
 
@@ -4037,215 +4034,264 @@ async function renderTree(
4037
4034
  }
4038
4035
  }
4039
4036
 
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
4037
  // Which build this document is, for the client to say on every navigation
4057
4038
  // - read from the document rather than learned from the first answer,
4058
4039
  // because a document a service worker served from its cache is the last
4059
4040
  // build's while the answers are this one's. An id, not a version number.
4060
4041
  if (bootstrap) head.push(createElement('meta', { key: '__v', name: 'rsc-kit:build', content: await buildId() }))
4061
4042
 
4062
- if (md) {
4043
+ // What the page's metadata adds - its title, description, share tags,
4044
+ // robots, icons - and the found icons and share images, made absolute
4045
+ // against its metadataBase.
4046
+ const metadataHead = (md: Record<string, unknown> | null): unknown[] => {
4047
+ const head: unknown[] = []
4048
+ const base = md?.metadataBase ? String(md.metadataBase) : null
4049
+ const absolute = (value: unknown): string => {
4050
+ const text = String(value instanceof URL ? value.href : value)
4063
4051
 
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)
4052
+ if (!base || /^[a-z][a-z0-9+.-]*:/i.test(text)) return text
4071
4053
 
4072
- const r = value as Record<string, unknown>
4073
- const parts: string[] = []
4054
+ return new URL(text, base).href
4055
+ }
4074
4056
 
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]))
4057
+ for (const [i, found] of APP_HEAD.entries()) {
4058
+ const props = { ...found.props }
4059
+
4060
+ if (props.content && /^(og:image|twitter:image)$/.test(props.property ?? props.name ?? '')) {
4061
+ props.content = absolute(props.content)
4083
4062
  }
4084
4063
 
4085
- return parts.join(', ')
4064
+ head.push(createElement(found.tag, { key: '__a' + i, ...props }))
4086
4065
  }
4087
4066
 
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
4067
+ if (md) {
4068
+ if (md.title != null) {
4069
+ // The element is what a server render puts in <head>, and what a route
4070
+ // with no runtime relies on entirely.
4071
+ head.push(createElement('title', { key: '__t' }, String(md.title)))
4092
4072
 
4093
- if (bot != null) head.push(createElement('meta', { key: '__rg', name: 'googlebot', content: robotsContent(bot) }))
4073
+ // And the effect is what keeps it right once pages are retained — see
4074
+ // DocumentTitle. Only where there is a runtime to run it: a client
4075
+ // component on a route that ships none is refused by the build.
4076
+ if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
4077
+ }
4078
+ if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
4079
+ // The name only, never the version - see the identify option.
4080
+ if (manifest().build?.identify) head.push(createElement('meta', { key: '__g', name: 'generator', content: 'rsc-kit' }))
4094
4081
  }
4095
4082
 
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
- })
4111
-
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]
4083
+ if (md) {
4116
4084
 
4117
- for (const item of list) {
4118
- if (item == null) continue
4085
+ // robots is a string, or the object Next takes: index and follow as
4086
+ // their no- forms, the flags by name, the limits as name:value. googleBot
4087
+ // is the same shape for the googlebot tag. The object used to fall
4088
+ // through to the catch-all below as "[object Object]" - which no crawler
4089
+ // reads, on the one page that asked not to be indexed.
4090
+ const robotsContent = (value: unknown): string => {
4091
+ if (typeof value !== 'object' || value === null) return String(value)
4119
4092
 
4120
- if (typeof item === 'object' && !(item instanceof URL)) {
4121
- const image = item as { url: unknown; width?: number; height?: number; alt?: string; type?: string }
4093
+ const r = value as Record<string, unknown>
4094
+ const parts: string[] = []
4122
4095
 
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))
4096
+ if (r.index != null) parts.push(r.index ? 'index' : 'noindex')
4097
+ if (r.follow != null) parts.push(r.follow ? 'follow' : 'nofollow')
4098
+ for (const flag of ['noarchive', 'nosnippet', 'noimageindex', 'nocache', 'notranslate', 'indexifembedded', 'nositelinkssearchbox']) {
4099
+ if (r[flag]) parts.push(flag)
4100
+ }
4101
+ if (r.unavailable_after != null) parts.push('unavailable_after: ' + String(r.unavailable_after))
4102
+ for (const limit of ['max-video-preview', 'max-image-preview', 'max-snippet']) {
4103
+ if (r[limit] != null) parts.push(limit + ':' + String(r[limit]))
4130
4104
  }
4105
+
4106
+ return parts.join(', ')
4131
4107
  }
4132
- }
4133
4108
 
4134
- if (md.openGraph) {
4135
- const og = md.openGraph as Record<string, unknown>
4109
+ if (md.robots != null) {
4110
+ head.push(createElement('meta', { key: '__r', name: 'robots', content: robotsContent(md.robots) }))
4136
4111
 
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
- }
4112
+ const bot = typeof md.robots === 'object' ? (md.robots as { googleBot?: unknown }).googleBot : null
4145
4113
 
4146
- if (md.twitter) {
4147
- const tw = md.twitter as Record<string, unknown>
4114
+ if (bot != null) head.push(createElement('meta', { key: '__rg', name: 'googlebot', content: robotsContent(bot) }))
4115
+ }
4148
4116
 
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
- }
4117
+ // og: and its relatives are PROPERTY, not name. Facebook's scraper - and
4118
+ // Slack's, and LinkedIn's - reads only property=, so every og tag this
4119
+ // used to emit with name= was invisible to the thing it existed for.
4120
+ // Twitter reads name=, which is why the two are not one rule.
4121
+ const isProperty = (k: string) => /^(og|article|profile|book|music|video|fb):/.test(k)
4122
+ // The keys whose CONTENT is a url. Exact, plus the :url and :secure_url
4123
+ // spellings - not og:image:width, which a looser match turned into
4124
+ // https://site/1200 and no scraper would ever read.
4125
+ const isUrlKey = (k: string) => /^(og:image|og:url|twitter:image)(:(secure_)?url)?$/.test(k)
4126
+ const tag = (k: string, v: unknown) =>
4127
+ createElement('meta', {
4128
+ key: '__m_' + k + '_' + String(v).slice(0, 40),
4129
+ [isProperty(k) ? 'property' : 'name']: k,
4130
+ content: isUrlKey(k) ? absolute(v) : String(v),
4131
+ })
4132
+
4133
+ // One image, or several. Each becomes its own og:image plus the size and
4134
+ // alt tags beside it, which is how a scraper is told which is which.
4135
+ const images = (prefix: string, value: unknown): void => {
4136
+ const list = Array.isArray(value) ? value : [value]
4137
+
4138
+ for (const item of list) {
4139
+ if (item == null) continue
4156
4140
 
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
- )
4141
+ if (typeof item === 'object' && !(item instanceof URL)) {
4142
+ const image = item as { url: unknown; width?: number; height?: number; alt?: string; type?: string }
4143
+
4144
+ head.push(tag(prefix, image.url))
4145
+ if (image.width) head.push(tag(prefix + ':width', image.width))
4146
+ if (image.height) head.push(tag(prefix + ':height', image.height))
4147
+ if (image.alt) head.push(tag(prefix + ':alt', image.alt))
4148
+ if (image.type) head.push(tag(prefix + ':type', image.type))
4149
+ } else {
4150
+ head.push(tag(prefix, item))
4151
+ }
4152
+ }
4180
4153
  }
4181
- }
4182
4154
 
4183
- if (md.icons != null) {
4184
- const icons = md.icons as Record<string, unknown> | string | unknown[]
4155
+ if (md.openGraph) {
4156
+ const og = md.openGraph as Record<string, unknown>
4185
4157
 
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)
4158
+ if (og.title != null) head.push(tag('og:title', og.title))
4159
+ if (og.description != null) head.push(tag('og:description', og.description))
4160
+ if (og.url != null) head.push(tag('og:url', og.url))
4161
+ if (og.siteName != null) head.push(tag('og:site_name', og.siteName))
4162
+ if (og.type != null) head.push(tag('og:type', og.type))
4163
+ if (og.locale != null) head.push(tag('og:locale', og.locale))
4164
+ if (og.images != null) images('og:image', og.images)
4193
4165
  }
4194
- }
4195
4166
 
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>
4167
+ if (md.twitter) {
4168
+ const tw = md.twitter as Record<string, unknown>
4201
4169
 
4202
- if (web.capable) {
4203
- head.push(tag('mobile-web-app-capable', 'yes'))
4204
- head.push(tag('apple-mobile-web-app-capable', 'yes'))
4170
+ if (tw.card != null) head.push(tag('twitter:card', tw.card))
4171
+ if (tw.title != null) head.push(tag('twitter:title', tw.title))
4172
+ if (tw.description != null) head.push(tag('twitter:description', tw.description))
4173
+ if (tw.site != null) head.push(tag('twitter:site', tw.site))
4174
+ if (tw.creator != null) head.push(tag('twitter:creator', tw.creator))
4175
+ if (tw.images != null) images('twitter:image', tw.images)
4205
4176
  }
4206
4177
 
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))
4209
-
4210
- if (web.startupImage != null) {
4211
- const startup = Array.isArray(web.startupImage) ? web.startupImage : [web.startupImage]
4178
+ // icons is links, not meta. A string is one icon; an object names which
4179
+ // rel each is for. The ones found in app/ are already in APP_HEAD above,
4180
+ // so this is for an app that wants to say it explicitly.
4181
+ const links = (rel: string, value: unknown): void => {
4182
+ const list = Array.isArray(value) ? value : [value]
4212
4183
 
4213
- for (const item of startup) {
4184
+ for (const item of list) {
4214
4185
  if (item == null) continue
4215
4186
 
4216
- const image = typeof item === 'object' && !(item instanceof URL)
4187
+ const icon = typeof item === 'object' && !(item instanceof URL)
4217
4188
  ? (item as Record<string, unknown>)
4218
4189
  : { url: item }
4219
4190
 
4220
4191
  head.push(
4221
4192
  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 } : {}),
4193
+ key: '__i_' + rel + '_' + String(icon.url).slice(0, 40),
4194
+ rel: (icon.rel as string) ?? rel,
4195
+ href: absolute(icon.url),
4196
+ ...(icon.type ? { type: icon.type } : {}),
4197
+ ...(icon.sizes ? { sizes: icon.sizes } : {}),
4198
+ ...(icon.media ? { media: icon.media } : {}),
4226
4199
  }),
4227
4200
  )
4228
4201
  }
4229
4202
  }
4230
- }
4231
4203
 
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))
4204
+ if (md.icons != null) {
4205
+ const icons = md.icons as Record<string, unknown> | string | unknown[]
4206
+
4207
+ if (typeof icons === 'string' || Array.isArray(icons) || icons instanceof URL) {
4208
+ links('icon', icons)
4209
+ } else {
4210
+ if (icons.icon != null) links('icon', icons.icon)
4211
+ if (icons.apple != null) links('apple-touch-icon', icons.apple)
4212
+ if (icons.shortcut != null) links('shortcut icon', icons.shortcut)
4213
+ if (icons.other != null) links('icon', icons.other)
4214
+ }
4215
+ }
4216
+
4217
+ // How the app behaves added to an iPhone's home screen - Next's shape.
4218
+ // Both capable names: Safari reads the apple- one, Chrome warns about it
4219
+ // and reads the plain one, and Next switched between them in a minor.
4220
+ if (md.appleWebApp != null && md.appleWebApp !== false) {
4221
+ const web = (md.appleWebApp === true ? { capable: true } : md.appleWebApp) as Record<string, unknown>
4222
+
4223
+ if (web.capable) {
4224
+ head.push(tag('mobile-web-app-capable', 'yes'))
4225
+ head.push(tag('apple-mobile-web-app-capable', 'yes'))
4226
+ }
4227
+
4228
+ if (web.title != null) head.push(tag('apple-mobile-web-app-title', web.title))
4229
+ if (web.statusBarStyle != null) head.push(tag('apple-mobile-web-app-status-bar-style', web.statusBarStyle))
4230
+
4231
+ if (web.startupImage != null) {
4232
+ const startup = Array.isArray(web.startupImage) ? web.startupImage : [web.startupImage]
4233
+
4234
+ for (const item of startup) {
4235
+ if (item == null) continue
4236
+
4237
+ const image = typeof item === 'object' && !(item instanceof URL)
4238
+ ? (item as Record<string, unknown>)
4239
+ : { url: item }
4240
+
4241
+ head.push(
4242
+ createElement('link', {
4243
+ key: '__s_' + String(image.url).slice(0, 60) + '_' + String(image.media ?? ''),
4244
+ rel: 'apple-touch-startup-image',
4245
+ href: absolute(image.url),
4246
+ ...(image.media ? { media: image.media } : {}),
4247
+ }),
4248
+ )
4249
+ }
4250
+ }
4251
+ }
4252
+
4253
+ // other is flattened in beside the named keys, because it is a place to put
4254
+ // meta tags rather than a meta tag by that name. A key at the top level
4255
+ // still renders - the type no longer invites one, but an app written
4256
+ // against the old shape must not silently lose its tags.
4257
+ const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'appleWebApp', 'other'])
4258
+ const named = Object.entries(md).filter(([k]) => !structured.has(k))
4259
+ const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
4260
+
4261
+ for (const [k, v] of [...named, ...extra]) {
4262
+ if (v == null) continue
4263
+
4264
+ if (Array.isArray(v)) {
4265
+ for (const item of v) head.push(tag(k, item))
4266
+ } else {
4267
+ head.push(tag(k, v))
4268
+ }
4247
4269
  }
4248
4270
  }
4271
+
4272
+
4273
+ return head
4274
+ }
4275
+
4276
+ // Static metadata - export const metadata on every file of the route -
4277
+ // costs nothing to resolve, and is in the shell. A generateMetadata is
4278
+ // data: it streams, under its own boundary, like any other read, so the
4279
+ // page paints at once and the title arrives when it is ready. Never held
4280
+ // up for it. A crawler, which needs the title and the status in what it
4281
+ // reads first, is answered once the whole document is ready - see the host.
4282
+ if (!metadataStreams(component, layouts)) {
4283
+ head.push(...metadataHead(await resolveMetadata(component, props, layouts, metadataParams)))
4284
+ } else {
4285
+ head.push(
4286
+ createElement(
4287
+ Suspense,
4288
+ { key: '__md', fallback: null },
4289
+ createElement(StreamedMetadata as never, {
4290
+ load: () => resolveMetadata(component, props, layouts, metadataParams, true),
4291
+ render: metadataHead,
4292
+ }),
4293
+ ),
4294
+ )
4249
4295
  }
4250
4296
 
4251
4297
  // Metadata elements are rendered INSIDE the document tree so React 19 hoists
@@ -4492,7 +4538,7 @@ export async function handleRscHtmlStream(
4492
4538
  nonce?: string,
4493
4539
  pageKey = '',
4494
4540
  bootstrap = true,
4495
- ): Promise<{ htmlStream: ReadableStream; rscPayloadPromise: Promise<string>; clientChunks: unknown }> {
4541
+ ): Promise<{ htmlStream: ReadableStream; rscPayload: ReadableStream; bootToken?: string; clientChunks: unknown }> {
4496
4542
  await instrumented()
4497
4543
  applyHost()
4498
4544
  await runMiddleware(component, props)
@@ -4500,11 +4546,18 @@ export async function handleRscHtmlStream(
4500
4546
  await renderTree(component, props, layouts, loadings, parallelSlots, slotOverrides, 0, pageKey, bootstrap),
4501
4547
  { onError: flightOnError },
4502
4548
  )
4503
- const [forHtml, forPayload] = flight.tee()
4504
- const rscPayloadPromise = new Response(forPayload).text()
4549
+ // Written into the bootstrap script, so it is in the document before the
4550
+ // runtime that reads it. Only where there is a runtime: a page shipping
4551
+ // none never comes back for a payload.
4552
+ const token = bootstrap ? bootToken() : undefined
4553
+ // The same tree twice: one copy becomes the HTML, the other is the payload
4554
+ // the browser comes back for to hydrate. The host holds it for that fetch
4555
+ // - see bootPayloads.ts - so a visit renders once. Streamed, not read to a
4556
+ // string: it used to be buffered whole here and never read by anyone.
4557
+ const [forHtml, rscPayload] = flight.tee()
4505
4558
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
4506
- const htmlStream = await ssr.handleSsr(forHtml, nonce, undefined, bootstrap, undefined, requestSearch())
4507
- return { htmlStream, rscPayloadPromise, clientChunks: {} }
4559
+ const htmlStream = await ssr.handleSsr(forHtml, nonce, undefined, bootstrap, undefined, requestSearch(), requestFromCrawler(), token)
4560
+ return { htmlStream, rscPayload, bootToken: token, clientChunks: {} }
4508
4561
  }
4509
4562
 
4510
4563
  /**
@@ -4550,7 +4603,7 @@ export async function handleRscFormPost(
4550
4603
  { onError: flightOnError },
4551
4604
  )
4552
4605
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
4553
- const htmlStream = await ssr.handleSsr(flight, nonce, undefined, bootstrap, formState, requestSearch())
4606
+ const htmlStream = await ssr.handleSsr(flight, nonce, undefined, bootstrap, formState, requestSearch(), requestFromCrawler())
4554
4607
 
4555
4608
  return { htmlStream }
4556
4609
  }
@@ -4588,7 +4641,7 @@ export async function handleRscResume(
4588
4641
  // hole by construction: at build the params never settled, so anything
4589
4642
  // reading them was postponed.
4590
4643
  params?: Record<string, string>,
4591
- ): Promise<{ htmlStream: ReadableStream }> {
4644
+ ): Promise<{ htmlStream: ReadableStream; replayed: () => boolean; rscPayload: ReadableStream }> {
4592
4645
  await instrumented()
4593
4646
  applyHost()
4594
4647
  await runMiddleware(component, params ?? props)
@@ -4620,11 +4673,10 @@ export async function handleRscResume(
4620
4673
  // rendered here for the url and hydrated against a payload that, like
4621
4674
  // the shell, does not know it. The browser fills it in.
4622
4675
  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,
4676
+ // The metadata is the real params' too. A generateMetadata streams
4677
+ // under its own boundary, which the pattern probe left as a hole; this
4678
+ // is what fills it, with the page's real title.
4679
+ undefined,
4628
4680
  ),
4629
4681
  { onError: flightOnError },
4630
4682
  )
@@ -4639,8 +4691,12 @@ export async function handleRscResume(
4639
4691
  // client-rendered. Set before the stream ends, because that is when
4640
4692
  // React reports it.
4641
4693
  let replayFailed = false
4694
+ // The same tree twice, as handleRscHtmlStream: the holes for the HTML, and
4695
+ // the payload the browser comes back for. The host holds the second for
4696
+ // that fetch - one render per visit.
4697
+ const [forHtml, rscPayload] = flight.tee()
4642
4698
  const htmlStream = await ssr.handleSsrResume(
4643
- flight,
4699
+ forHtml,
4644
4700
  postponed,
4645
4701
  nonce,
4646
4702
  () => {
@@ -4649,7 +4705,7 @@ export async function handleRscResume(
4649
4705
  requestSearch(),
4650
4706
  )
4651
4707
 
4652
- return { htmlStream, replayed: () => !replayFailed }
4708
+ return { htmlStream, replayed: () => !replayFailed, rscPayload }
4653
4709
  }
4654
4710
 
4655
4711
  // Server action (worker: rsc-action).
@@ -4984,6 +5040,11 @@ export async function resolveMetadata(
4984
5040
  // The page's own params promise, when the caller has one. During the
4985
5041
  // pattern probe it never settles - see handleRscPprShell.
4986
5042
  params?: Promise<Record<string, unknown>>,
5043
+ // Rendered under its own boundary: no grace to race. A generateMetadata
5044
+ // waiting on params that never settle - the build's pattern probe - is a
5045
+ // hole like any other read, filled per request with the real title,
5046
+ // instead of an empty one frozen into the shell.
5047
+ streamed = false,
4987
5048
  ): Promise<Record<string, unknown> | null> {
4988
5049
  await instrumented()
4989
5050
 
@@ -5010,7 +5071,7 @@ export async function resolveMetadata(
5010
5071
  searchParams: pageSearchParams(),
5011
5072
  })
5012
5073
 
5013
- const settled = params
5074
+ const settled = params && !streamed
5014
5075
  ? await Promise.race([
5015
5076
  Promise.resolve(generated),
5016
5077
  new Promise<typeof METADATA_POSTPONED>((resolve) =>
@@ -5700,6 +5761,12 @@ export async function handleSsr(
5700
5761
  bootstrap = true,
5701
5762
  formState?: unknown,
5702
5763
  search?: string | null,
5764
+ // A crawler's request: answered once everything has rendered - see
5765
+ // crawler.ts - so the host reads the page's final word on its status.
5766
+ whole = false,
5767
+ // The boot token: the document's render kept its payload under it, and
5768
+ // the runtime redeems it instead of asking for a second render.
5769
+ bootToken?: string,
5703
5770
  ): Promise<ReadableStream> {
5704
5771
  const root = withSearch(await createFromReadableStream(rscStream), search)
5705
5772
 
@@ -5710,7 +5777,9 @@ export async function handleSsr(
5710
5777
  // seconds before the runtime hydrates is held for it rather than given
5711
5778
  // to the browser as a document load. See earlyClicks.ts.
5712
5779
  const bootstrapScriptContent = bootstrap
5713
- ? EARLY + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
5780
+ ? (bootToken ? 'self.__rsc_boot=' + JSON.stringify(bootToken) + ';' : '') +
5781
+ EARLY +
5782
+ (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
5714
5783
  : undefined
5715
5784
 
5716
5785
  // Without an onError handler React rejects each abortable task on its own,
@@ -5725,6 +5794,8 @@ export async function handleSsr(
5725
5794
  ...(formState !== undefined ? { formState: formState as any } : {}),
5726
5795
  })
5727
5796
 
5797
+ if (whole) await html.allReady
5798
+
5728
5799
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
5729
5800
  }
5730
5801