@storystick/next 0.0.0-stage → 0.2.0-rc.0

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.
Files changed (121) hide show
  1. package/LICENSE +112 -0
  2. package/PUBLIC-API.md +31 -0
  3. package/README.md +522 -2
  4. package/dist/.source-hash +1 -0
  5. package/dist/cache-tags.d.ts +2 -0
  6. package/dist/cache-tags.js +12 -0
  7. package/dist/clean.d.ts +1 -0
  8. package/dist/clean.js +1 -0
  9. package/dist/code-version.d.ts +29 -0
  10. package/dist/code-version.js +48 -0
  11. package/dist/file.d.ts +1 -0
  12. package/dist/file.js +1 -0
  13. package/dist/i18n-seo.d.ts +1 -0
  14. package/dist/i18n-seo.js +1 -0
  15. package/dist/image.d.ts +11 -0
  16. package/dist/image.js +22 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +3 -0
  19. package/dist/index.react-server.d.ts +3 -0
  20. package/dist/index.react-server.js +12 -0
  21. package/dist/link.d.ts +10 -0
  22. package/dist/link.js +8 -0
  23. package/dist/preview/index.d.ts +29 -0
  24. package/dist/preview/index.js +24 -0
  25. package/dist/preview/internal.d.ts +61 -0
  26. package/dist/preview/internal.js +97 -0
  27. package/dist/preview/preview-request-identity.d.ts +4 -0
  28. package/dist/preview/preview-request-identity.js +63 -0
  29. package/dist/preview/provenance-config.d.ts +42 -0
  30. package/dist/preview/provenance-config.js +132 -0
  31. package/dist/preview/provenance-frame-builder.d.ts +6 -0
  32. package/dist/preview/provenance-frame-builder.js +10 -0
  33. package/dist/preview/provenance-response.d.ts +5 -0
  34. package/dist/preview/provenance-response.js +9 -0
  35. package/dist/preview/provenance-runtime-adapter.d.ts +9 -0
  36. package/dist/preview/provenance-runtime-adapter.js +42 -0
  37. package/dist/preview/render-frame-provenance.d.ts +23 -0
  38. package/dist/preview/render-frame-provenance.js +47 -0
  39. package/dist/preview/route-authoring.d.ts +3 -0
  40. package/dist/preview/route-authoring.js +80 -0
  41. package/dist/preview/route-context.d.ts +3 -0
  42. package/dist/preview/route-context.js +38 -0
  43. package/dist/preview/route-disable.d.ts +7 -0
  44. package/dist/preview/route-disable.js +63 -0
  45. package/dist/preview/route-document-provenance.d.ts +23 -0
  46. package/dist/preview/route-document-provenance.js +46 -0
  47. package/dist/preview/route-errors.d.ts +1 -0
  48. package/dist/preview/route-errors.js +1 -0
  49. package/dist/preview/route-handler.d.ts +36 -0
  50. package/dist/preview/route-handler.js +139 -0
  51. package/dist/preview/route-initial.d.ts +9 -0
  52. package/dist/preview/route-initial.js +141 -0
  53. package/dist/preview/route-provenance-runtime.d.ts +34 -0
  54. package/dist/preview/route-provenance-runtime.js +90 -0
  55. package/dist/preview/route-token.d.ts +6 -0
  56. package/dist/preview/route-token.js +46 -0
  57. package/dist/preview/server-preview-internals.d.ts +3 -0
  58. package/dist/preview/server-preview-internals.js +3 -0
  59. package/dist/preview/slice-locale.d.ts +5 -0
  60. package/dist/preview/slice-locale.js +15 -0
  61. package/dist/preview-error-surface.d.ts +7 -0
  62. package/dist/preview-error-surface.js +24 -0
  63. package/dist/preview-exit.d.ts +7 -0
  64. package/dist/preview-exit.js +71 -0
  65. package/dist/preview-metadata.d.ts +15 -0
  66. package/dist/preview-metadata.js +13 -0
  67. package/dist/preview-mode-latch.d.ts +133 -0
  68. package/dist/preview-mode-latch.js +231 -0
  69. package/dist/preview-nav-location.d.ts +12 -0
  70. package/dist/preview-nav-location.js +149 -0
  71. package/dist/preview-nav.d.ts +94 -0
  72. package/dist/preview-nav.js +523 -0
  73. package/dist/preview-refresh-coordinator.d.ts +26 -0
  74. package/dist/preview-refresh-coordinator.js +93 -0
  75. package/dist/preview-render-error.d.ts +11 -0
  76. package/dist/preview-render-error.js +36 -0
  77. package/dist/preview-render-recovery.d.ts +9 -0
  78. package/dist/preview-render-recovery.js +42 -0
  79. package/dist/preview-session-recovery.d.ts +1 -0
  80. package/dist/preview-session-recovery.js +1 -0
  81. package/dist/preview-standalone-host.d.ts +3 -0
  82. package/dist/preview-standalone-host.js +55 -0
  83. package/dist/preview-text.d.ts +2 -0
  84. package/dist/preview-text.js +1 -0
  85. package/dist/preview-view-bootstrap.d.ts +5 -0
  86. package/dist/preview-view-bootstrap.js +13 -0
  87. package/dist/public-delivery.d.ts +6 -0
  88. package/dist/public-delivery.js +7 -0
  89. package/dist/query-fetch-options.d.ts +7 -0
  90. package/dist/query-fetch-options.js +193 -0
  91. package/dist/query.d.ts +2 -0
  92. package/dist/query.js +1 -0
  93. package/dist/rendering.d.ts +33 -0
  94. package/dist/rendering.js +42 -0
  95. package/dist/revalidate.d.ts +12 -0
  96. package/dist/revalidate.js +33 -0
  97. package/dist/rich-text.d.ts +2 -0
  98. package/dist/rich-text.js +1 -0
  99. package/dist/server-runtime.d.ts +208 -0
  100. package/dist/server-runtime.js +1584 -0
  101. package/dist/server.d.ts +8 -0
  102. package/dist/server.js +6 -0
  103. package/dist/slice-zone.d.ts +2 -0
  104. package/dist/slice-zone.js +1 -0
  105. package/dist/storystick-component-types.d.ts +26 -0
  106. package/dist/storystick-component-types.js +1 -0
  107. package/dist/storystick-component.d.ts +10 -0
  108. package/dist/storystick-component.js +45 -0
  109. package/dist/storystick-error-recovery.d.ts +12 -0
  110. package/dist/storystick-error-recovery.js +11 -0
  111. package/dist/storystick-preview-runtime.d.ts +30 -0
  112. package/dist/storystick-preview-runtime.js +561 -0
  113. package/dist/storystick.d.ts +24 -0
  114. package/dist/storystick.js +23 -0
  115. package/dist/testing.d.ts +9 -0
  116. package/dist/testing.js +40 -0
  117. package/dist/video.d.ts +1 -0
  118. package/dist/video.js +1 -0
  119. package/package.json +128 -3
  120. package/recipes/README.md +91 -0
  121. package/recipes/resource-mirror.ts +49 -0
@@ -0,0 +1,523 @@
1
+ 'use client';
2
+ import { createPreviewRequestId } from '@storystick/preview/runtime/request-id';
3
+ import { previewRuntime } from '@storystick/preview/runtime/store';
4
+ import { previewPerfNow, recordPreviewPerfMeasure, recordPreviewPerfMetric } from '@storystick/preview/runtime/perf-metrics';
5
+ import { confirmPreviewRender, resetPreviewRenderFailure } from './preview-render-recovery.js';
6
+ import { readPreviewRenderReceipt } from '@storystick/preview/framework/render-receipt';
7
+ /**
8
+ * Next.js preview navigation adapter (ADR-087 Lane D').
9
+ *
10
+ * Wires the framework-agnostic `setNavigateHandler` exported by
11
+ * `@storystick/preview` (Lane D contract) to Next's router so a
12
+ * Studio-issued `storystick.preview.navigate` postMessage drives a
13
+ * client-side `router.replace(pathname)` inside the preview iframe.
14
+ *
15
+ * **Honest claim per ADR §3.2 / §7** : this is **best-effort CSR
16
+ * navigation**, NOT *"framework-agnostic navigation"*.
17
+ *
18
+ * - **App Router (RSC)** : `router.replace(pathname)` triggers a CSR
19
+ * transition. The preview selector is preserved and the destination
20
+ * Server Components resolve their short-lived access token from the
21
+ * durable HttpOnly PreviewGrant.
22
+ * - **Pages Router** : `router.replace(pathname)` performs the usual
23
+ * client-side transition. `getServerSideProps` re-runs and
24
+ * preserves the Storystick preview selector in the destination URL.
25
+ *
26
+ * **Why no fully-automatic router auto-detect** : the brief's first
27
+ * draft tried calling `useRouter` from BOTH `next/navigation` and
28
+ * `next/router` unconditionally and picking the first non-null
29
+ * result, but Next's hooks throw outside their respective contexts
30
+ * (codex round 1 P0) — `next/navigation::useRouter` throws in Pages
31
+ * Router (Next 15 invariant E238), and `next/router::useRouter`
32
+ * throws in App Router. Detecting "which router context am I in?"
33
+ * without internal Next APIs is impossible inside a single hook.
34
+ *
35
+ * The robust fix : we use **`next/compat/router::useRouter`**, the
36
+ * Next-officially-supported universal hook that returns the Pages
37
+ * `NextRouter` instance when mounted inside Pages Router, returns
38
+ * `null` otherwise (App Router or no-router env), and **never
39
+ * throws**. App Router consumers pass their `useRouter()` instance
40
+ * (from `next/navigation`) explicitly as the optional `appRouter`
41
+ * argument. The hook prefers the explicit App Router argument when
42
+ * provided, otherwise falls back to the compat-detected Pages
43
+ * router.
44
+ *
45
+ * Usage — App Router :
46
+ *
47
+ * ```tsx
48
+ * 'use client'
49
+ * import { useRouter } from 'next/navigation'
50
+ * import { StorystickQueryProvider } from '@storystick/next'
51
+ * import { useStorystickPreviewNavigation } from '@storystick/next/preview-nav'
52
+ *
53
+ * export function Providers({ children }: { children: React.ReactNode }) {
54
+ * const router = useRouter()
55
+ * useStorystickPreviewNavigation(router)
56
+ * return (
57
+ * <StorystickQueryProvider projectId="..." publishableKey="...">
58
+ * {children}
59
+ * </StorystickQueryProvider>
60
+ * )
61
+ * }
62
+ * ```
63
+ *
64
+ * Usage — Pages Router (no argument needed) :
65
+ *
66
+ * ```tsx
67
+ * import { StorystickQueryProvider } from '@storystick/next'
68
+ * import { useStorystickPreviewNavigation } from '@storystick/next/preview-nav'
69
+ *
70
+ * export function Providers({ children }: { children: React.ReactNode }) {
71
+ * useStorystickPreviewNavigation()
72
+ * return (
73
+ * <StorystickQueryProvider projectId="..." publishableKey="...">
74
+ * {children}
75
+ * </StorystickQueryProvider>
76
+ * )
77
+ * }
78
+ * ```
79
+ *
80
+ * **Why a separate subpath** : `@storystick/next` (root) is imported
81
+ * by Server Components for `fetchDocument` / `fetchQuery`. The
82
+ * preview hook drags `next/compat/router` and React client-only
83
+ * primitives, which would pollute the server module graph (codex
84
+ * Lane D' round 2 P1 fix). Keeping the hook at
85
+ * `@storystick/next/preview-nav` keeps the server entry pure.
86
+ *
87
+ * **Sentinel guard** : the hook is a no-op when not in preview mode.
88
+ * Preview mode = embedded iframe (`window.parent !== window`) AND a
89
+ * `_ss_session` URL parameter — same heuristic as `detectPreviewMode`
90
+ * in `@storystick/react`'s provider. Outside preview mode the hook
91
+ * never registers a handler and `setNavigateHandler` keeps its
92
+ * default no-op.
93
+ *
94
+ * **Cleanup** : on unmount the hook restores a surviving layout adapter,
95
+ * or the no-op default when none remains. External overrides stay intact.
96
+ *
97
+ * **Hook vs imperative** : `useRouter` from Next can ONLY be called
98
+ * inside React components, which forces the public surface to be a
99
+ * hook. Consumers place this hook in their root client component
100
+ * (typically the same component that renders `<StorystickQueryProvider>`).
101
+ */
102
+ import { useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react';
103
+ import { useRouter as useCompatRouter } from 'next/compat/router';
104
+ // ADR-116 FU2 — synchronous post-render commit avoids an HMR re-mount
105
+ // race where a residual `activate()` listener delivers a navigate
106
+ // envelope between the new mount's render and a passive `useEffect`
107
+ // commit, dispatching to the no-op handler.
108
+ const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
109
+ import { setNavigateHandler, getNavigateHandler, setReloadHandler, getReloadHandler, } from '@storystick/preview/nav-handler';
110
+ import { notifyRouterTransition } from '@storystick/preview/router-subscriber';
111
+ import { isPreviewModeLatched, readPreviewSessionIdForNavigation, readPreviewVariantIdForNavigation, readPreviewReleaseIdForNavigation, setLatchedPreviewScope, } from './preview-mode-latch.js';
112
+ import { beginPreviewNavigation, cancelPreviewRefreshCoordination, observePreviewNavigationCommit, } from './preview-refresh-coordinator.js';
113
+ import { PREVIEW_SESSION_QUERY_PARAM, writePreviewScopeToSearchParams, } from '@storystick/preview/identity';
114
+ // A nested error fallback may coexist with the layout adapter. When it leaves,
115
+ // return control to the latest still-mounted adapter, never to a stale closure.
116
+ const navigationAdapters = new Set();
117
+ /**
118
+ * ADR-108 D6.bis — strip a configured basePath from a browser-form
119
+ * pathname. Pure function exported for tests.
120
+ *
121
+ * stripBasePath('/admin', '/admin/page') → '/page'
122
+ * stripBasePath('/admin/', '/admin/page') → '/page' (trailing slash normalised)
123
+ * stripBasePath('/admin', '/admin') → '/' (exact match → root)
124
+ * stripBasePath('/admin', '/administrator') → '/administrator' (segment-boundary)
125
+ * stripBasePath('', '/page') → '/page' (no-op)
126
+ */
127
+ export function stripBasePath(basePath, pathname) {
128
+ if (basePath === '' || basePath === '/')
129
+ return pathname;
130
+ // Normalise: ensure leading slash, drop trailing slash.
131
+ let bp = basePath;
132
+ if (!bp.startsWith('/'))
133
+ bp = '/' + bp;
134
+ if (bp.endsWith('/'))
135
+ bp = bp.replace(/\/+$/, '');
136
+ if (bp === '')
137
+ return pathname;
138
+ if (!pathname.startsWith(bp))
139
+ return pathname;
140
+ const rest = pathname.slice(bp.length);
141
+ // Path-segment boundary: rest must be empty (exact match) OR start
142
+ // with `/` (next segment). `/administrator` does NOT match
143
+ // basePath `/admin` because rest would be `istrator` (no leading
144
+ // slash) — bail out.
145
+ if (rest === '')
146
+ return '/';
147
+ if (!rest.startsWith('/'))
148
+ return pathname;
149
+ return rest;
150
+ }
151
+ /**
152
+ * Preserve the `_ss_session` URL parameter when navigating inside
153
+ * the preview iframe. Studio's `storystick.preview.navigate`
154
+ * envelope only carries the destination `pathname` ; if we push it
155
+ * raw, the current `?_ss_session=...` marker is dropped and
156
+ * `<StorystickQueryProvider>`'s `detectPreviewMode()` re-evaluates to
157
+ * false on the next render, tearing down the PreviewGrant-backed
158
+ * Convex preview subscription mid-session (codex Lane D' round 4
159
+ * P1).
160
+ *
161
+ * Source precedence (ADR-113 follow-up — codex R4 finding #2):
162
+ * 1. **Destination's own `_ss_session`**: if Studio explicitly put
163
+ * a session id in the destination path, respect it and do not
164
+ * overwrite.
165
+ * 2. **Latched preview session id**: the boot-time value captured
166
+ * by `isPreviewModeLatched()`. Studio-authoritative, stable
167
+ * across customer intra-iframe nav that strips the URL param.
168
+ * 3. **Live `window.location.search`**: fallback only. Useful when
169
+ * the latch is empty (e.g. an old SDK build that didn't track
170
+ * sessionId AND the URL never lost it) — the opportunistic
171
+ * backfill in `isPreviewModeLatched()` normally upgrades the
172
+ * latch before we get here.
173
+ *
174
+ * The exact variant/release selector is carried with the same destination so
175
+ * the request-owned read scope survives customer navigation. A trusted Studio
176
+ * command replaces the latched scope atomically; living clears both selectors.
177
+ *
178
+ * The pathname is already validated upstream as a same-origin
179
+ * absolute path (starts with `/`, not `//`) by
180
+ * `validatePreviewEnvelope`.
181
+ */
182
+ function preservePreviewSession(pathname, explicitScope) {
183
+ if (typeof window === 'undefined')
184
+ return pathname;
185
+ // ADR-116 PR5 — preview identity facade owns the precedence chain:
186
+ // identity layer first, latch fallback second, live URL bootstrap
187
+ // last. Keep adapter code away from direct URL/storage/latch reads.
188
+ const currentSession = readPreviewSessionIdForNavigation();
189
+ if (currentSession === null)
190
+ return pathname;
191
+ const currentVariant = readPreviewVariantIdForNavigation();
192
+ const currentRelease = readPreviewReleaseIdForNavigation();
193
+ // Split the destination on `?` to merge query strings cleanly.
194
+ // The pathname may already include a query (e.g. `/about?foo=bar`)
195
+ // and/or a hash (e.g. `/about#section`). We preserve those.
196
+ const hashIndex = pathname.indexOf('#');
197
+ const hash = hashIndex >= 0 ? pathname.slice(hashIndex) : '';
198
+ const pathWithQuery = hashIndex >= 0 ? pathname.slice(0, hashIndex) : pathname;
199
+ const queryIndex = pathWithQuery.indexOf('?');
200
+ const path = queryIndex >= 0 ? pathWithQuery.slice(0, queryIndex) : pathWithQuery;
201
+ const existingQuery = queryIndex >= 0 ? pathWithQuery.slice(queryIndex + 1) : '';
202
+ const params = new URLSearchParams(existingQuery);
203
+ // If the destination already specifies `_ss_session`, respect it.
204
+ if (!params.has(PREVIEW_SESSION_QUERY_PARAM)) {
205
+ params.set(PREVIEW_SESSION_QUERY_PARAM, currentSession);
206
+ }
207
+ if (explicitScope !== undefined) {
208
+ // Studio navigation is authoritative. In particular, living scope must
209
+ // remove a variant/release left in the current tab URL instead of
210
+ // inheriting it accidentally.
211
+ writePreviewScopeToSearchParams(params, explicitScope);
212
+ }
213
+ else if (currentRelease !== null) {
214
+ writePreviewScopeToSearchParams(params, {
215
+ kind: 'release',
216
+ releaseId: currentRelease,
217
+ });
218
+ }
219
+ else if (currentVariant !== null) {
220
+ writePreviewScopeToSearchParams(params, {
221
+ kind: 'variant',
222
+ variantId: currentVariant,
223
+ });
224
+ }
225
+ const merged = params.toString();
226
+ return merged.length > 0 ? `${path}?${merged}${hash}` : `${path}${hash}`;
227
+ }
228
+ /**
229
+ * Wire `@storystick/preview`'s navigate handler to a Next router.
230
+ *
231
+ * @param appRouter — App Router instance from `next/navigation`'s
232
+ * `useRouter()`. App Router consumers pass it explicitly (see
233
+ * module JSDoc). Optional — when omitted, the hook falls back to
234
+ * the Pages Router instance auto-detected via `next/compat/router`.
235
+ *
236
+ * No-op outside preview mode. On unmount, restores a surviving adapter or
237
+ * the default no-op handler, leaving no stale registration behind.
238
+ */
239
+ export function useStorystickPreviewNavigation(appRouter, options) {
240
+ const [refreshPending, startRefresh] = useTransition();
241
+ const [refreshCommand, setRefreshCommand] = useState(null);
242
+ const navigationRevisionRef = useRef(0);
243
+ useEffect(() => {
244
+ if (refreshPending || refreshCommand === null || typeof window === 'undefined')
245
+ return;
246
+ const target = new URL(refreshCommand.destination, window.location.href);
247
+ const basePath = options?.basePath ?? '';
248
+ const receipt = readPreviewRenderReceipt();
249
+ recordPreviewPerfMeasure(window, 'next.navigate.refreshSettled', refreshCommand.startedAt, {
250
+ freshReceipt: receipt !== null && receipt.renderId !== refreshCommand.previousRenderId,
251
+ });
252
+ if (target.pathname === stripBasePath(basePath, window.location.pathname) && target.search === window.location.search && target.hash === window.location.hash && confirmPreviewRender()) {
253
+ // App Router location hooks do not fire when only the draft identity
254
+ // changes. React's transition settlement supplies that render boundary.
255
+ notifyRouterTransition({
256
+ pathname: target.pathname,
257
+ search: target.search.replace(/^\?/, ''), hash: target.hash,
258
+ commandId: refreshCommand.commandId,
259
+ ...(receipt && receipt.renderId !== refreshCommand.previousRenderId
260
+ ? { presentation: { ...receipt, pathname: target.pathname } } : {}),
261
+ });
262
+ }
263
+ setRefreshCommand(null);
264
+ }, [refreshCommand, refreshPending, options?.basePath]);
265
+ // `next/compat/router::useRouter` is the only Next router hook
266
+ // that is safe to call unconditionally in BOTH App Router and
267
+ // Pages Router contexts (it returns `NextRouter | null` and never
268
+ // throws). The compat hook reads from Pages Router's
269
+ // `RouterContext`, which is null in App Router → returns null →
270
+ // we fall through to the explicit `appRouter` argument.
271
+ const pagesRouter = useCompatRouter();
272
+ // App Router argument wins when provided AND has a usable
273
+ // `replace` ; otherwise fall back to the auto-detected Pages
274
+ // router. If neither is available we leave `setNavigateHandler`
275
+ // untouched (default no-op).
276
+ const router = appRouter !== undefined &&
277
+ appRouter !== null &&
278
+ typeof appRouter.replace === 'function'
279
+ ? appRouter
280
+ : pagesRouter !== null && typeof pagesRouter.replace === 'function'
281
+ ? pagesRouter
282
+ : null;
283
+ useIsoLayoutEffect(() => {
284
+ // ADR-116 PR8 — register the handler UNCONDITIONALLY (gated only
285
+ // on `router !== null`). The preview-mode / identity gate moves
286
+ // INSIDE the handler body so dispatches fire correctly even after
287
+ // a hard navigation inside the iframe.
288
+ //
289
+ // Pre-PR8 the effect bailed at mount when `isPreviewModeLatched()`
290
+ // returned false. After a hard-nav the new realm boots with the
291
+ // URL stripped of `_ss_session` AND `globalThis` reset, so the
292
+ // synchronous latch read returned false. Identity arrived ~50-300ms
293
+ // later via the resolver's storage carrier / Studio session-broadcast
294
+ // (ADR-116 D3) but the `useEffect` deps `[router]` did NOT change,
295
+ // so the effect never re-ran → handler never registered → Studio's
296
+ // navigate envelopes silently dropped by the default no-op
297
+ // `getNavigateHandler()`. Forward channel dead post-hard-nav.
298
+ //
299
+ // The fix : register unconditionally, gate at dispatch time. The
300
+ // observable behaviour for non-preview mode is unchanged (handler
301
+ // bails immediately) but the post-recovery dispatch flows because
302
+ // the handler is already wired by the time identity arrives.
303
+ //
304
+ // ADR-113 latch seed (preserved side effect) — call
305
+ // `isPreviewModeLatched()` once at mount, ignoring the return
306
+ // value. The function's first invocation populates
307
+ // `globalThis.__storystick_preview_mode_latched__` from the boot
308
+ // URL state. Without this eager seed, the cache stays undefined
309
+ // until a customer intra-iframe nav (which can strip `_ss_session`
310
+ // from the URL) AND the first dispatch — which would then capture
311
+ // the post-strip URL state and lose the original sessionId. The
312
+ // dispatch-time `isPreviewModeLatched()` inside the handler reads
313
+ // the now-cached value, not the live URL.
314
+ isPreviewModeLatched();
315
+ if (router === null)
316
+ return;
317
+ // Hold the handler we register so the cleanup can verify that the
318
+ // currently-installed handler is still ours before clearing it.
319
+ // Without this guard, two adapters mounted concurrently would
320
+ // race : the second `setNavigateHandler` wins, but unmounting the
321
+ // FIRST adapter would then clear the second one's handler (codex
322
+ // Lane D' round 3 P2 fix).
323
+ const ourHandler = async (pathname, navigateOptions) => {
324
+ // ADR-116 PR8 dispatch-time gate. The handler is registered
325
+ // unconditionally at mount ; this check ensures we never call
326
+ // `router.replace()` outside preview mode. After hard-nav
327
+ // recovery, `isPreviewModeLatched()` reads through to
328
+ // `previewRuntime.getIdentity()` (PR5 read-through) and returns
329
+ // true once identity is restored.
330
+ if (!isPreviewModeLatched())
331
+ return;
332
+ // Pathname is already validated as a same-origin absolute
333
+ // path (starts with `/`, not `//`) by
334
+ // `validatePreviewEnvelope` in the foundation Lane C — see
335
+ // `nav-handler.ts` JSDoc. Trust the value here.
336
+ //
337
+ // We MUST preserve `_ss_session` across the replace,
338
+ // otherwise `<StorystickQueryProvider>`'s `detectPreviewMode()`
339
+ // re-evaluates to false on the next render and tears
340
+ // down the PreviewGrant-backed Convex preview subscription
341
+ // (codex Lane D' round 4 P1 fix).
342
+ //
343
+ // Studio retains its own history (replace). Explicit standalone selector
344
+ // gestures request push; both transports use this same framework adapter.
345
+ const revision = ++navigationRevisionRef.current;
346
+ let scope = previewRuntime.getState().session?.readContext?.perspective;
347
+ if (navigateOptions?.contextCommand && navigateOptions.commandId) {
348
+ const startedAt = previewPerfNow(window);
349
+ const context = await previewRuntime.changePerspective({
350
+ ...navigateOptions.contextCommand, commandId: navigateOptions.commandId,
351
+ perspective: navigateOptions.scope,
352
+ }, preservePreviewSession(`${options?.basePath ?? ''}/api/storystick/preview/context`));
353
+ recordPreviewPerfMeasure(window, 'next.navigate.context', startedAt, { accepted: context !== null });
354
+ if (!context || navigationRevisionRef.current !== revision)
355
+ return;
356
+ scope = context.perspective;
357
+ }
358
+ const destination = preservePreviewSession(pathname, scope);
359
+ if (scope !== undefined) {
360
+ setLatchedPreviewScope(scope);
361
+ options?.onScopeChange?.(scope);
362
+ }
363
+ // ADR-111 still requires a root-layout refresh when Studio changed the
364
+ // preview target. It must not run concurrently with `replace()`: both
365
+ // start Flight transitions, and overlapping responses can corrupt the
366
+ // Next development client. The location adapter settles this intent
367
+ // after it observes the committed destination. Exact content refreshes
368
+ // arriving in the meantime are folded into that single post-commit call.
369
+ const shouldReplace = beginPreviewNavigation({
370
+ destination,
371
+ currentLocation: typeof window === 'undefined' ? destination :
372
+ `${stripBasePath(options?.basePath ?? '', window.location.pathname)}${window.location.search}${window.location.hash}`,
373
+ ...(navigateOptions?.refresh === true &&
374
+ typeof router.refresh === 'function'
375
+ ? {
376
+ refresh: () => { router.refresh?.(); },
377
+ runRefresh: (refresh) => {
378
+ if (navigationRevisionRef.current !== revision)
379
+ return;
380
+ recordPreviewPerfMetric(window, { name: 'next.navigate.refresh' });
381
+ if (navigateOptions.commandId)
382
+ setRefreshCommand({
383
+ destination, commandId: navigateOptions.commandId,
384
+ previousRenderId: readPreviewRenderReceipt()?.renderId,
385
+ startedAt: previewPerfNow(window),
386
+ });
387
+ // Reset and data refresh belong to the same transition.
388
+ // Next otherwise keeps same-path errors latched (draft → home).
389
+ startRefresh(() => { resetPreviewRenderFailure(); refresh(); });
390
+ },
391
+ }
392
+ : {}),
393
+ });
394
+ if (shouldReplace || (navigateOptions?.refresh === true && typeof router.refresh !== 'function')) {
395
+ recordPreviewPerfMetric(window, { name: 'next.navigate.replace' });
396
+ // Pages Router has no refresh(). A private query revision forces its
397
+ // data navigation even for the exact same URL and fragment (which
398
+ // Next otherwise treats as a hash-only transition without new data).
399
+ if (navigateOptions?.refresh === true && typeof router.refresh !== 'function' && router === pagesRouter) {
400
+ const refreshed = new URL(destination, 'https://preview.invalid');
401
+ refreshed.searchParams.set('_ss_refresh', navigateOptions.commandId ?? createPreviewRequestId());
402
+ startRefresh(() => {
403
+ resetPreviewRenderFailure();
404
+ router[navigateOptions?.history === 'push' ? 'push' : 'replace'](`${refreshed.pathname}${refreshed.search}${refreshed.hash}`);
405
+ });
406
+ }
407
+ else
408
+ startRefresh(() => {
409
+ resetPreviewRenderFailure();
410
+ if (navigateOptions?.history === 'push')
411
+ router.push(destination);
412
+ else
413
+ router.replace(destination);
414
+ });
415
+ }
416
+ };
417
+ setNavigateHandler(ourHandler);
418
+ const ourReloadHandler = (mode) => {
419
+ if (!isPreviewModeLatched())
420
+ return;
421
+ if (mode === 'soft') {
422
+ if (typeof router.refresh === 'function') {
423
+ startRefresh(() => { resetPreviewRenderFailure(); router.refresh?.(); });
424
+ }
425
+ return;
426
+ }
427
+ if (typeof window !== 'undefined') {
428
+ window.location.reload();
429
+ }
430
+ };
431
+ setReloadHandler(ourReloadHandler);
432
+ const registration = { navigate: ourHandler, reload: ourReloadHandler };
433
+ navigationAdapters.add(registration);
434
+ return () => {
435
+ navigationRevisionRef.current += 1;
436
+ navigationAdapters.delete(registration);
437
+ const survivingAdapter = [...navigationAdapters].at(-1);
438
+ // Only restore a survivor/default when WE'RE still the registered
439
+ // handler. If another adapter (or override) installed
440
+ // its own handler after us, leave it alone — clearing
441
+ // would silently break that adapter.
442
+ if (getNavigateHandler() === ourHandler) {
443
+ setNavigateHandler(survivingAdapter?.navigate ?? null);
444
+ cancelPreviewRefreshCoordination();
445
+ }
446
+ if (getReloadHandler() === ourReloadHandler) {
447
+ setReloadHandler(survivingAdapter?.reload ?? null);
448
+ }
449
+ };
450
+ // The router reference from Next's hooks is stable across
451
+ // renders (Next memoises both instances internally), so
452
+ // depending on it here is safe.
453
+ }, [router, pagesRouter, options?.onScopeChange, options?.basePath]);
454
+ // ADR-108 D6 — Pages Router location-subscriber wiring. App Router
455
+ // consumers must additionally call `useStorystickPreviewLocation` —
456
+ // it lives in a separate module to avoid pulling `next/navigation`
457
+ // hooks into this entry's module graph.
458
+ //
459
+ // For Pages Router we attach `router.events.on('routeChangeComplete',
460
+ // cb)` and emit a transition tuple to the SDK's
461
+ // `notifyRouterTransition`. basePath: auto-default from
462
+ // `pagesRouter.basePath` (NextRouter public API). Hash is read from
463
+ // `window.location.hash` (events.on doesn't surface it).
464
+ //
465
+ // App Router consumers fall through this effect (the explicit
466
+ // `appRouter` argument wins above, leaving `pagesRouter` null when
467
+ // App context is in play), so no subscriber registration races
468
+ // with the App-side hook.
469
+ const explicitBasePath = options?.basePath;
470
+ useIsoLayoutEffect(() => {
471
+ // ADR-116 PR8 — same pattern as the navigate handler above : wire
472
+ // `events.on('routeChangeComplete', ...)` unconditionally and gate
473
+ // INSIDE the callback. Pre-PR8 the synchronous latch check at
474
+ // effect-start bailed on a fresh post-hard-nav realm, and the
475
+ // deps `[appRouter, pagesRouter, explicitBasePath]` did not
476
+ // include identity — so the subscriber was never wired and
477
+ // reverse-channel emissions from Pages Router customers died
478
+ // post-hard-nav.
479
+ isPreviewModeLatched(); // seed the globalThis latch eagerly
480
+ // Only wire the Pages Router events path when the resolved
481
+ // router IS the pagesRouter (i.e. no explicit appRouter was
482
+ // provided, OR appRouter was provided but unusable and we fell
483
+ // back to pages). Detecting via `pagesRouter !== null AND
484
+ // !appRouter` is the simplest invariant.
485
+ if (appRouter)
486
+ return;
487
+ if (pagesRouter === null)
488
+ return;
489
+ // Type-narrow the compat router instance to the Pages NextRouter
490
+ // shape we need: events.on/.off + a readable basePath. The
491
+ // compat router exposes both by spec, but the RouterReplaceable
492
+ // narrowed type doesn't include them — re-narrow locally.
493
+ const pr = pagesRouter;
494
+ if (!pr.events || typeof pr.events.on !== 'function')
495
+ return;
496
+ const basePath = explicitBasePath ?? pr.basePath ?? '';
497
+ const onRouteChange = (url) => {
498
+ // ADR-116 PR8 dispatch-time gate. Skip the emission when not in
499
+ // preview mode (top-level page, identity not yet restored, etc.).
500
+ if (!isPreviewModeLatched())
501
+ return;
502
+ // `url` is the Pages Router's "as" path (e.g.
503
+ // `/admin/page?x=1`); not the rewritten internal route.
504
+ // Split into pathname / search; hash is read separately.
505
+ const hashIdx = url.indexOf('#');
506
+ const beforeHash = hashIdx >= 0 ? url.slice(0, hashIdx) : url;
507
+ // events.on doesn't surface the hash (the Pages router
508
+ // doesn't fire on pure hash changes), but hash present on
509
+ // a route change is read from window.location.
510
+ const hash = typeof window !== 'undefined' ? window.location.hash : '';
511
+ const queryIdx = beforeHash.indexOf('?');
512
+ const rawPath = queryIdx >= 0 ? beforeHash.slice(0, queryIdx) : beforeHash;
513
+ const search = queryIdx >= 0 ? beforeHash.slice(queryIdx + 1) : '';
514
+ const pathname = stripBasePath(basePath, rawPath);
515
+ observePreviewNavigationCommit(`${pathname}${search ? `?${search}` : ''}${hash}`);
516
+ notifyRouterTransition({ pathname, search, hash, browserPathname: rawPath });
517
+ };
518
+ pr.events.on('routeChangeComplete', onRouteChange);
519
+ return () => {
520
+ pr.events?.off('routeChangeComplete', onRouteChange);
521
+ };
522
+ }, [appRouter, pagesRouter, explicitBasePath]);
523
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Serializes framework refreshes around App Router navigation commits.
3
+ *
4
+ * `router.replace()` and `router.refresh()` both start React Flight
5
+ * transitions. Issuing them back-to-back can make two responses race inside
6
+ * Next's Flight client. We therefore wait for the location hook to observe the
7
+ * committed destination, then issue at most one refresh. A projection-ready
8
+ * content refresh arriving meanwhile supersedes the navigation-only refresh.
9
+ */
10
+ export declare const NAVIGATION_COMMIT_FALLBACK_MS = 8000;
11
+ export declare function beginPreviewNavigation(input: {
12
+ readonly destination: string;
13
+ readonly currentLocation: string;
14
+ readonly refresh?: () => void;
15
+ /** Preserve command settlement when a content refresh replaces the physical refresh. */
16
+ readonly runRefresh?: (refresh: () => void) => void;
17
+ }): boolean;
18
+ /** Called by the framework location adapter after a destination commit. */
19
+ export declare function observePreviewNavigationCommit(committedLocation: string): boolean;
20
+ /**
21
+ * Run an exact content refresh now, or fold it into the navigation refresh
22
+ * that will run immediately after the destination commits.
23
+ */
24
+ export declare function requestPreviewContentRefresh(refresh: () => void): void;
25
+ export declare function cancelPreviewRefreshCoordination(): void;
26
+ export declare function _resetPreviewRefreshCoordinatorForTests(): void;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Serializes framework refreshes around App Router navigation commits.
3
+ *
4
+ * `router.replace()` and `router.refresh()` both start React Flight
5
+ * transitions. Issuing them back-to-back can make two responses race inside
6
+ * Next's Flight client. We therefore wait for the location hook to observe the
7
+ * committed destination, then issue at most one refresh. A projection-ready
8
+ * content refresh arriving meanwhile supersedes the navigation-only refresh.
9
+ */
10
+ export const NAVIGATION_COMMIT_FALLBACK_MS = 8_000;
11
+ let pendingNavigation = null;
12
+ let deferredContentRefresh = null;
13
+ export function beginPreviewNavigation(input) {
14
+ clearPendingNavigation();
15
+ if (sameBrowserLocation(input.destination, input.currentLocation)) {
16
+ const refresh = deferredContentRefresh ?? input.refresh ?? null;
17
+ deferredContentRefresh = null;
18
+ if (refresh)
19
+ (input.runRefresh ?? ((run) => run()))(refresh);
20
+ return false;
21
+ }
22
+ const pending = {
23
+ destination: input.destination,
24
+ refresh: input.refresh ?? null,
25
+ runRefresh: input.runRefresh,
26
+ timer: setTimeout(() => {
27
+ if (pendingNavigation !== pending)
28
+ return;
29
+ settlePendingNavigation();
30
+ }, NAVIGATION_COMMIT_FALLBACK_MS),
31
+ };
32
+ pendingNavigation = pending;
33
+ return true;
34
+ }
35
+ /** Called by the framework location adapter after a destination commit. */
36
+ export function observePreviewNavigationCommit(committedLocation) {
37
+ if (pendingNavigation === null ||
38
+ !sameBrowserLocation(pendingNavigation.destination, committedLocation)) {
39
+ return false;
40
+ }
41
+ settlePendingNavigation();
42
+ return true;
43
+ }
44
+ /**
45
+ * Run an exact content refresh now, or fold it into the navigation refresh
46
+ * that will run immediately after the destination commits.
47
+ */
48
+ export function requestPreviewContentRefresh(refresh) {
49
+ if (pendingNavigation !== null) {
50
+ deferredContentRefresh = refresh;
51
+ return;
52
+ }
53
+ refresh();
54
+ }
55
+ export function cancelPreviewRefreshCoordination() {
56
+ clearPendingNavigation();
57
+ deferredContentRefresh = null;
58
+ }
59
+ function settlePendingNavigation() {
60
+ const pending = pendingNavigation;
61
+ if (pending === null)
62
+ return;
63
+ clearTimeout(pending.timer);
64
+ pendingNavigation = null;
65
+ const refresh = deferredContentRefresh ?? pending.refresh;
66
+ deferredContentRefresh = null;
67
+ if (refresh)
68
+ (pending.runRefresh ?? ((run) => run()))(refresh);
69
+ }
70
+ function clearPendingNavigation() {
71
+ if (pendingNavigation === null)
72
+ return;
73
+ clearTimeout(pendingNavigation.timer);
74
+ pendingNavigation = null;
75
+ }
76
+ function sameBrowserLocation(destination, currentLocation) {
77
+ try {
78
+ const current = new URL(currentLocation, 'https://preview.invalid');
79
+ const target = new URL(destination, current);
80
+ // Pages Router uses this private revision only to force a data load.
81
+ current.searchParams.delete('_ss_refresh');
82
+ target.searchParams.delete('_ss_refresh');
83
+ return (target.pathname === current.pathname &&
84
+ target.search === current.search &&
85
+ target.hash === current.hash);
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ export function _resetPreviewRefreshCoordinatorForTests() {
92
+ cancelPreviewRefreshCoordination();
93
+ }
@@ -0,0 +1,11 @@
1
+ export declare const PREVIEW_READ_UNAVAILABLE_DIGEST = "storystick.preview.read-unavailable.v1";
2
+ export declare const PREVIEW_CONTEXT_SUPERSEDED_DIGEST = "storystick.preview.context-superseded.v1";
3
+ export declare const CUSTOMER_RENDER_ERROR = "runtime.customer_render_failed";
4
+ export declare const PREVIEW_READ_UNAVAILABLE = "runtime.preview_read_unavailable";
5
+ export declare function previewReadUnavailableDigest(retryAfterMs: number | undefined, now: number): string;
6
+ export declare function previewRenderError(digest: string | undefined): {
7
+ retryNotBefore?: number | undefined;
8
+ code: string;
9
+ message: string;
10
+ retryable: boolean;
11
+ };