@wular/pnext 0.0.7 → 0.0.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.
@@ -1,8 +1,9 @@
1
1
  import { h, type VNode } from 'preact'
2
2
  import { isElementLike } from '../utils/serialize'
3
+ import { ISLAND_STATIC_SLOT_ATTRIBUTE, ISLAND_STATIC_SLOT_MARKER } from './static-slots-revive'
3
4
 
4
- export const ISLAND_STATIC_SLOT_ATTRIBUTE = 'data-pnext-static-slot'
5
- export const ISLAND_STATIC_SLOT_MARKER = '$$pnext_slot'
5
+ // The client half lives in ./static-slots-revive (preact-free); re-exported so importers keep one entry point.
6
+ export * from './static-slots-revive'
6
7
 
7
8
  type Props = Record<string, unknown>
8
9
 
@@ -52,55 +53,3 @@ function mapSlots(
52
53
  if (Array.isArray(value)) return mapped.map(([, item]) => item)
53
54
  return Object.fromEntries(mapped)
54
55
  }
55
-
56
- /** Cheap gate on the raw props attribute so islands with no element props skip the revive walk. */
57
- export function hasIslandStaticSlots(raw: string) {
58
- return raw.includes(ISLAND_STATIC_SLOT_MARKER)
59
- }
60
-
61
- /**
62
- * Client mount: swap each `$$pnext_slot` marker for a `pnext-static-slot` host holding the matching
63
- * server-rendered DOM, converted to vnodes by the entry's DOM walker (so nested islands inside the
64
- * adopted subtree become real island vnodes and hydrate on their own). The content is static server
65
- * markup - it never re-renders, same as element children.
66
- */
67
- export async function reviveIslandStaticSlots(
68
- props: Props,
69
- root: ParentNode,
70
- toChildren: (node: ParentNode) => unknown,
71
- ): Promise<Props> {
72
- return (await reviveSlots(props, root, toChildren, new Set())) as Props
73
- }
74
-
75
- async function reviveSlots(
76
- value: unknown,
77
- root: ParentNode,
78
- toChildren: (node: ParentNode) => unknown,
79
- seen: Set<object>,
80
- ): Promise<unknown> {
81
- if (value === null || typeof value !== 'object' || seen.has(value)) return value
82
- const marker = (value as Props)[ISLAND_STATIC_SLOT_MARKER]
83
- if (typeof marker === 'string') {
84
- const node = root.querySelector(`[${ISLAND_STATIC_SLOT_ATTRIBUTE}="${cssEscape(marker)}"]`)
85
- // No server markup for this slot (the island never rendered the prop, or it was skipped for
86
- // SSR): nothing to adopt, so the prop arrives null rather than as an empty host.
87
- if (!node) return null
88
- return h(
89
- 'pnext-static-slot',
90
- { [ISLAND_STATIC_SLOT_ATTRIBUTE]: marker, style: { display: 'contents' } },
91
- (await toChildren(node)) as VNode,
92
- )
93
- }
94
- const proto = Object.getPrototypeOf(value) as object | null
95
- if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return value
96
- seen.add(value)
97
- const target = value as Props
98
- for (const key of Object.keys(target)) {
99
- target[key] = await reviveSlots(target[key], root, toChildren, seen)
100
- }
101
- return value
102
- }
103
-
104
- function cssEscape(value: string) {
105
- return value.replace(/["\\]/g, '\\$&')
106
- }
@@ -19,6 +19,12 @@ const SET_MARKER = '$$pnext_set'
19
19
  // restoring identity. Only true cycles are encoded this way - a value that merely appears twice in different
20
20
  // branches is still expanded, so every acyclic payload is byte-identical to what it was before.
21
21
  const REF_MARKER = '$$pnext_ref'
22
+ // Promise-valued island props ride as `{__pnextPromise: resolved}` so the value keeps its PROMISE
23
+ // IDENTITY across the wire - top level (page params) and nested in plain containers (a react-query
24
+ // dehydrated state carries its pending-query promise inside `state.queries[n].promise`). The render
25
+ // half writes the marker (render/slots promiseMarker); both revive halves live here so the
26
+ // preact-free client entry can import them.
27
+ export const PROMISE_MARKER_KEY = '__pnextPromise'
22
28
 
23
29
  type TypedArray =
24
30
  | Int8Array
@@ -197,6 +203,49 @@ function refTarget(root: unknown, node: Record<string, unknown>): unknown {
197
203
  return current
198
204
  }
199
205
 
206
+ /** Cheap gate: an island whose raw props carry no marker skips the revive walk entirely. */
207
+ export function hasPromiseProps(raw: string) {
208
+ return raw.includes(PROMISE_MARKER_KEY)
209
+ }
210
+
211
+ /**
212
+ * Revive every `promiseMarker` in `props` - top level and nested inside plain objects/arrays - into a
213
+ * PRE-FULFILLED promise (React `use()` protocol: status/value readable synchronously). A bare
214
+ * `Promise.resolve` would make `use()` suspend during hydration, and a suspended hydration re-render
215
+ * appends fragment siblings instead of reusing the server DOM.
216
+ *
217
+ * Nested containers are rewritten in place (the client just parsed them; the server already
218
+ * serialized the wire bytes before this runs), the root is not.
219
+ */
220
+ export function revivePromiseMarkers<T>(props: T): T {
221
+ return reviveMarkers(props, new Set()) as T
222
+ }
223
+
224
+ function reviveMarkers(value: unknown, seen: Set<object>): unknown {
225
+ if (value === null || typeof value !== 'object' || seen.has(value)) return value
226
+ if (PROMISE_MARKER_KEY in value) {
227
+ return fulfilledPromise((value as Record<string, unknown>)[PROMISE_MARKER_KEY])
228
+ }
229
+ seen.add(value)
230
+ if (Array.isArray(value)) {
231
+ for (let index = 0; index < value.length; index++) {
232
+ value[index] = reviveMarkers(value[index], seen)
233
+ }
234
+ return value
235
+ }
236
+ if (Object.getPrototypeOf(value) !== Object.prototype) return value
237
+ const target = value as Record<string, unknown>
238
+ for (const key of Object.keys(target)) target[key] = reviveMarkers(target[key], seen)
239
+ return value
240
+ }
241
+
242
+ function fulfilledPromise(value: unknown) {
243
+ const promise = Promise.resolve(value) as Promise<unknown> & { status: string; value: unknown }
244
+ promise.status = 'fulfilled'
245
+ promise.value = value
246
+ return promise
247
+ }
248
+
200
249
  /** A preact vnode: `h` sets `constructor` to undefined on every one it makes. */
201
250
  export function isElementLike(value: unknown): boolean {
202
251
  if (value === null || typeof value !== 'object') return false
@@ -227,6 +276,14 @@ export function assertSerializable(
227
276
  if (value instanceof Date || value instanceof URL) {
228
277
  throw new Error(`${path} must be a plain JSON value`)
229
278
  }
279
+ // A promise the render half could not await: island props are awaited at the top level and inside
280
+ // plain objects/arrays/Maps/Sets, so anything left here sits behind a class instance (or is a
281
+ // server-action argument, where promises are never allowed). Say so instead of "plain object".
282
+ if (typeof (value as { then?: unknown }).then === 'function') {
283
+ throw new Error(
284
+ `${path} is a Promise: a promise prop must be reachable at the top level of the props or nested in plain objects/arrays/Maps/Sets - one held by a class instance cannot be awaited or serialized, and server actions take no promises at all`,
285
+ )
286
+ }
230
287
  if (typeof value !== 'object') return
231
288
  // A value that contains ITSELF is serializable: encodeBinary writes it as a
232
289
  // `$$pnext_ref` back-reference (React flight does the same), so stop walking
@@ -241,8 +298,8 @@ export function assertSerializable(
241
298
  return
242
299
  }
243
300
 
244
- // The slot walk (render/static-slots) never enters Map/Set, so elements inside them must
245
- // still throw rather than ship mangled.
301
+ // Elements stay illegal inside Map/Set: the slot walk (render/static-slots) does not enter
302
+ // them, so allowing one would silently ship a JSON-mangled vnode instead of a `$$pnext_slot`.
246
303
  if (value instanceof Map) {
247
304
  let index = 0
248
305
  for (const [key, item] of value.entries()) {