@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,231 @@
1
+ /**
2
+ * Preview identity latch (ADR-113 D2 + follow-up).
3
+ *
4
+ * Both customer SDK hooks (`useStorystickPreviewNavigation` and
5
+ * `useStorystickPreviewLocation`) plus the forward-path session
6
+ * preservation in `preview-nav.ts` need a stable answer to two
7
+ * questions:
8
+ *
9
+ * 1. *"Is this page running inside a Storystick preview iframe?"*
10
+ * (boolean — gates whether to register handlers)
11
+ * 2. *"What is the active `_ss_session` identifier?"*
12
+ * (string — appended to Studio-dispatched destinations so
13
+ * subsequent App Router navigates stay inside the preview
14
+ * session)
15
+ *
16
+ * The original heuristic — re-derive from `window.location.search` on
17
+ * every render — breaks the moment the customer's own intra-iframe
18
+ * nav drops `_ss_session` from the URL: the gate flips to false (PR5
19
+ * fix), and the destination injection loses the marker (this fix).
20
+ *
21
+ * t0 boot: /?_ss_session=X → active=true, sessionId=X
22
+ * t1 nav: /pricing → active=true, sessionId=X (cached)
23
+ * t2 onward: /pricing → active=true, sessionId=X (cached)
24
+ *
25
+ * The latch lives on `globalThis` (not module scope) so it survives
26
+ * HMR: Next/Fast Refresh re-evaluates modules but never replaces the
27
+ * `globalThis` object, so a module-scope `let` would be lost on HMR
28
+ * while a `globalThis.__storystick_preview_mode_latched__` write is
29
+ * preserved by construction.
30
+ *
31
+ * **Backward-compatible shape**: `sessionId` is OPTIONAL on the
32
+ * stored object. An older `@storystick/next` version that wrote just
33
+ * `{ active }` (pre-follow-up) is read by the new code as
34
+ * `existing.sessionId === undefined` — the new code then runs
35
+ * opportunistic backfill: if the URL currently has `_ss_session`, it
36
+ * upgrades the in-place latch object so subsequent reads pick up the
37
+ * id. Symmetric direction (new writer / old reader) is trivially
38
+ * safe — the old reader only ever inspected `.active`.
39
+ *
40
+ * **Why not bump the namespace key**: two `@storystick/next` versions
41
+ * in the same process MUST share the active-preview decision (the
42
+ * gate). Bumping the key would partition the gate too, reintroducing
43
+ * the pre-PR5 bug for the older copy. Codex R4 follow-up consult
44
+ * 2026-05-14: keep namespace, extend shape.
45
+ *
46
+ * **Tri-state via object presence**: a raw boolean cannot distinguish
47
+ * "latched and false" from "not latched yet". The `PreviewLatch`
48
+ * object is the sentinel: `existing !== undefined` means latched
49
+ * (active or not); the boolean inside carries the decision.
50
+ *
51
+ * **Namespace stability**: `__storystick_preview_mode_latched__` is
52
+ * the single, underscore-prefixed key. The literal-string grep test
53
+ * in `preview-mode-latch.test.ts` pins it; do not rename even if
54
+ * "mode" is now an under-description (the latch carries identity too).
55
+ */
56
+ import { previewRuntime } from '@storystick/preview/runtime/store';
57
+ import { detectPreviewSessionFromWindow, readPreviewIdentitySessionIdFromSource, } from '@storystick/preview/identity';
58
+ /**
59
+ * ADR-116 PR5 — read the active identity from the preview runtime
60
+ * store. Returns `null` when :
61
+ * - the runtime is mocked without `getIdentity` (legacy test mocks),
62
+ * - the runtime has not been initialized yet (pre-boot),
63
+ * - we are outside preview mode (identity is null).
64
+ *
65
+ * When non-null, the identity layer is authoritative — the
66
+ * `globalThis.__storystick_preview_mode_latched__` cache becomes a
67
+ * pre-init / cross-version fallback.
68
+ */
69
+ function tryReadIdentitySessionId() {
70
+ return readPreviewIdentitySessionIdFromSource(previewRuntime);
71
+ }
72
+ /**
73
+ * Return whether the current page is running inside a Storystick
74
+ * preview iframe. Latches on first valid call: subsequent calls return
75
+ * the cached value even after the URL drops `_ss_session` (customer
76
+ * intra-iframe nav).
77
+ *
78
+ * Also performs opportunistic sessionId backfill: if the existing
79
+ * latch is missing `sessionId` (either old SDK shape or first mount
80
+ * happened mid-customer-nav) AND the URL currently carries
81
+ * `_ss_session`, the value is written into the latch in place so
82
+ * `getLatchedPreviewSessionId()` returns it on subsequent calls.
83
+ *
84
+ * SSR / no-`window` calls return false without latching — they cannot
85
+ * know yet, and we must not freeze the wrong answer for the eventual
86
+ * client hydration.
87
+ */
88
+ export function isPreviewModeLatched() {
89
+ if (previewRuntime.getState?.().previewEnded)
90
+ return false;
91
+ // ADR-116 PR5 — identity layer is authoritative when populated.
92
+ // Fallback to the globalThis latch + URL path covers pre-init
93
+ // (synchronous render before init resolves) AND legacy test mocks.
94
+ if (tryReadIdentitySessionId() !== null)
95
+ return true;
96
+ if (typeof globalThis === 'undefined')
97
+ return false;
98
+ const existing = globalThis.__storystick_preview_mode_latched__;
99
+ if (existing !== undefined) {
100
+ // Opportunistic backfill: an old-shape latch (or a latch written
101
+ // when the URL happened to lack the param at first read) is
102
+ // upgraded in place if the URL has since regained `_ss_session`.
103
+ // Only attempted when active=true — top-level pages never carry
104
+ // a meaningful session id, so we don't capture URL noise.
105
+ if (existing.active &&
106
+ (existing.sessionId === undefined ||
107
+ existing.sessionId === null ||
108
+ existing.variantId === undefined ||
109
+ existing.releaseId === undefined) &&
110
+ typeof window !== 'undefined') {
111
+ const detected = detectPreviewSessionFromWindow(window);
112
+ if (detected.active &&
113
+ detected.sessionId !== null &&
114
+ (existing.sessionId === undefined || existing.sessionId === null)) {
115
+ existing.sessionId = detected.sessionId;
116
+ }
117
+ if (detected.active &&
118
+ detected.variantId !== null &&
119
+ existing.variantId === undefined) {
120
+ existing.variantId = detected.variantId;
121
+ }
122
+ if (detected.active &&
123
+ detected.releaseId !== null &&
124
+ existing.releaseId === undefined) {
125
+ existing.releaseId = detected.releaseId;
126
+ }
127
+ }
128
+ return existing.active;
129
+ }
130
+ if (typeof window === 'undefined')
131
+ return false;
132
+ const detected = detectPreviewSessionFromWindow(window);
133
+ globalThis.__storystick_preview_mode_latched__ = detected;
134
+ return detected.active;
135
+ }
136
+ /**
137
+ * Return the latched `_ss_session` identifier. `null` when the latch
138
+ * has not run, the page is not in preview mode, or the latch was
139
+ * captured before the param was available (an old SDK version, or a
140
+ * first read that happened mid-customer-nav).
141
+ *
142
+ * Callers in the SDK forward path (`preservePreviewSession`) use this
143
+ * as the primary source over `window.location.search`: the latch is
144
+ * Studio-authoritative (set at boot from the URL Studio constructed),
145
+ * while the live URL is customer-mutable surface that intra-iframe
146
+ * nav routinely strips.
147
+ *
148
+ * The caller is responsible for invoking `isPreviewModeLatched()`
149
+ * first (or otherwise gating on preview mode) before consuming this
150
+ * value — outside preview the latch is never written and this
151
+ * returns `null` unconditionally.
152
+ */
153
+ export function getLatchedPreviewSessionId() {
154
+ if (previewRuntime.getState?.().previewEnded)
155
+ return null;
156
+ // ADR-116 PR5 — identity layer wins ; the globalThis latch becomes a
157
+ // fallback substrate for pre-init / legacy contexts.
158
+ const fromIdentity = tryReadIdentitySessionId();
159
+ if (fromIdentity !== null)
160
+ return fromIdentity;
161
+ if (typeof globalThis === 'undefined')
162
+ return null;
163
+ return globalThis.__storystick_preview_mode_latched__?.sessionId ?? null;
164
+ }
165
+ export function readPreviewSessionIdForNavigation() {
166
+ if (previewRuntime.getState?.().previewEnded)
167
+ return null;
168
+ const fromIdentity = tryReadIdentitySessionId();
169
+ if (fromIdentity !== null)
170
+ return fromIdentity;
171
+ const fromLatch = getLatchedPreviewSessionId();
172
+ if (fromLatch !== null)
173
+ return fromLatch;
174
+ if (typeof window === 'undefined')
175
+ return null;
176
+ return detectPreviewSessionFromWindow(window).sessionId;
177
+ }
178
+ export function readPreviewVariantIdForNavigation() {
179
+ if (typeof globalThis !== 'undefined') {
180
+ const latch = globalThis.__storystick_preview_mode_latched__;
181
+ if (latch?.active && latch.variantId !== undefined) {
182
+ return typeof latch.variantId === 'string' && latch.variantId.length > 0
183
+ ? latch.variantId
184
+ : null;
185
+ }
186
+ }
187
+ if (typeof window === 'undefined')
188
+ return null;
189
+ return detectPreviewSessionFromWindow(window).variantId;
190
+ }
191
+ export function readPreviewReleaseIdForNavigation() {
192
+ if (typeof globalThis !== 'undefined') {
193
+ const latch = globalThis.__storystick_preview_mode_latched__;
194
+ if (latch?.active && latch.releaseId !== undefined) {
195
+ return typeof latch.releaseId === 'string' && latch.releaseId.length > 0
196
+ ? latch.releaseId
197
+ : null;
198
+ }
199
+ }
200
+ if (typeof window === 'undefined')
201
+ return null;
202
+ return detectPreviewSessionFromWindow(window).releaseId;
203
+ }
204
+ /**
205
+ * Replace the latched request scope after a trusted Studio navigation.
206
+ * Living scope deliberately clears both selectors so a later customer-side
207
+ * navigation cannot resurrect an older variant or release from the boot URL.
208
+ */
209
+ export function setLatchedPreviewScope(scope) {
210
+ if (typeof globalThis === 'undefined')
211
+ return;
212
+ const current = globalThis.__storystick_preview_mode_latched__;
213
+ if (current === undefined || !current.active)
214
+ return;
215
+ current.variantId = scope.kind === 'variant' ? scope.variantId : null;
216
+ current.releaseId = scope.kind === 'release' ? scope.releaseId : null;
217
+ }
218
+ /**
219
+ * Test-only reset. Vitest's module isolation re-imports modules between
220
+ * tests but `globalThis` is shared across the suite, so a latch set in
221
+ * test A would leak into test B. Tests call this in `beforeEach` to
222
+ * start each case with a fresh latch.
223
+ *
224
+ * Production code MUST NOT call this — clearing the latch reintroduces
225
+ * the very bug it exists to prevent.
226
+ */
227
+ export function _resetPreviewModeLatchForTests() {
228
+ if (typeof globalThis !== 'undefined') {
229
+ globalThis.__storystick_preview_mode_latched__ = undefined;
230
+ }
231
+ }
@@ -0,0 +1,12 @@
1
+ import { type PreviewNavOptions } from './preview-nav.js';
2
+ export declare function sanitizePreviewLocationSearch(searchParams: URLSearchParams): string;
3
+ /**
4
+ * Register the App Router-side `subscribeRouter` adapter. The hook
5
+ * reads pathname + searchParams from `next/navigation` and emits a
6
+ * transition tuple on every change.
7
+ *
8
+ * @param options.basePath — `next.config.js` basePath. App Router
9
+ * does not expose basePath publicly so customers using a configured
10
+ * basePath MUST pass it here. Defaults to `''` (no stripping).
11
+ */
12
+ export declare function useStorystickPreviewLocation(options?: PreviewNavOptions): void;
@@ -0,0 +1,149 @@
1
+ 'use client';
2
+ /**
3
+ * Next App Router preview-location adapter (ADR-108 D6).
4
+ *
5
+ * Companion to `useStorystickPreviewNavigation` — App Router consumers
6
+ * call BOTH hooks:
7
+ *
8
+ * ```tsx
9
+ * 'use client'
10
+ * import { useRouter } from 'next/navigation'
11
+ * import { useStorystickPreviewNavigation } from '@storystick/next/preview-nav'
12
+ * import { useStorystickPreviewLocation } from '@storystick/next/preview-nav-location'
13
+ *
14
+ * export function Providers({ children }) {
15
+ * const router = useRouter()
16
+ * useStorystickPreviewNavigation(router)
17
+ * useStorystickPreviewLocation()
18
+ * return <StorystickQueryProvider …>{children}</StorystickQueryProvider>
19
+ * }
20
+ * ```
21
+ *
22
+ * Why split: `usePathname` and `useSearchParams` from `next/navigation`
23
+ * throw in Pages Router context. Importing them at module scope is
24
+ * safe (no execution), but calling them must be gated on App Router.
25
+ * Splitting the hook into its own module makes the call site
26
+ * unambiguous: customers in App Router call it, Pages Router
27
+ * customers don't.
28
+ *
29
+ * For Pages Router, `useStorystickPreviewNavigation` itself wires
30
+ * `router.events.on('routeChangeComplete')` and reports transitions
31
+ * automatically — Pages consumers don't need this hook.
32
+ *
33
+ * **Hash handling**: `usePathname`/`useSearchParams` don't expose the
34
+ * fragment. We read `window.location.hash` synchronously inside the
35
+ * effect after the (pathname, search) tuple changes — Next App Router
36
+ * scrolls to the hash before our effect runs, so the hash we see is
37
+ * the post-navigation value.
38
+ *
39
+ * **rAF coalescing**: multiple synchronous transitions in a single
40
+ * frame collapse to a single emission (the last one). Mirror of the
41
+ * `section-bounds` reporter pattern.
42
+ */
43
+ import { useEffect, useLayoutEffect } from 'react';
44
+ import { usePathname, useSearchParams } from 'next/navigation';
45
+ import { notifyRouterTransition } from '@storystick/preview/router-subscriber';
46
+ import { recordPreviewPerfMetric } from '@storystick/preview/runtime/perf-metrics';
47
+ import { stripBasePath } from './preview-nav.js';
48
+ import { isPreviewModeLatched } from './preview-mode-latch.js';
49
+ import { observePreviewNavigationCommit } from './preview-refresh-coordinator.js';
50
+ import { confirmPreviewRender } from './preview-render-recovery.js';
51
+ // ADR-116 FU2 — synchronous post-render commit avoids an HMR re-mount
52
+ // race where a residual `activate()` listener fires before a passive
53
+ // `useEffect` commits the new mount's wiring.
54
+ const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
55
+ export function sanitizePreviewLocationSearch(searchParams) {
56
+ const sanitized = new URLSearchParams(searchParams);
57
+ for (const key of [...sanitized.keys()]) {
58
+ if (key.startsWith('_ss_'))
59
+ sanitized.delete(key);
60
+ }
61
+ return sanitized.toString();
62
+ }
63
+ /**
64
+ * Register the App Router-side `subscribeRouter` adapter. The hook
65
+ * reads pathname + searchParams from `next/navigation` and emits a
66
+ * transition tuple on every change.
67
+ *
68
+ * @param options.basePath — `next.config.js` basePath. App Router
69
+ * does not expose basePath publicly so customers using a configured
70
+ * basePath MUST pass it here. Defaults to `''` (no stripping).
71
+ */
72
+ export function useStorystickPreviewLocation(options) {
73
+ const pathname = usePathname();
74
+ const searchParams = useSearchParams();
75
+ const basePath = options?.basePath ?? '';
76
+ useIsoLayoutEffect(() => {
77
+ // ADR-116 PR8 — schedule the rAF tick UNCONDITIONALLY (gated only
78
+ // on pathname / searchParams being non-null). The preview-mode /
79
+ // identity gate moves INSIDE `tick()` so a post-hard-nav re-mount
80
+ // schedules its first emission against the current pathname AND
81
+ // re-evaluates the latch at emit time — by which point the resolver
82
+ // has typically restored identity via the storage carrier or Studio
83
+ // session-broadcast (ADR-116 D3). Pre-PR8 the synchronous latch
84
+ // check at effect-start bailed and the first post-recovery pathname
85
+ // emission was lost until the next router transition.
86
+ isPreviewModeLatched(); // seed the globalThis latch eagerly
87
+ if (pathname === null)
88
+ return;
89
+ // Codex R3 finding #1 — `useSearchParams()` returns `null` during
90
+ // the initial SSR/CSR hand-off (`?` value not yet hydrated).
91
+ // Emitting now would post an envelope with a stale empty search
92
+ // string before the real value lands; bail until the next render.
93
+ if (searchParams === null)
94
+ return;
95
+ let cancelled = false;
96
+ let rafId = null;
97
+ // rAF coalescing — multiple synchronous transitions collapse to
98
+ // the last value within a single frame. The customer's router
99
+ // sometimes fires `usePathname`/`useSearchParams` change twice
100
+ // on a single route change (Next 15 known quirk on transitions
101
+ // that change both segments and query); coalescing keeps Studio's
102
+ // listener from seeing duplicates.
103
+ const tick = () => {
104
+ rafId = null;
105
+ if (cancelled)
106
+ return;
107
+ // ADR-116 PR8 dispatch-time gate. Skip the emission when not in
108
+ // preview mode (top-level page, identity not yet restored, etc.).
109
+ // `notifyRouterTransition` is a NOOP without a registered
110
+ // subscriber AND `emitLocation` inside activate() additionally
111
+ // guards on `parentOriginLock === null`, but the explicit gate
112
+ // here keeps observable behaviour symmetric with the navigate
113
+ // handler and avoids the rAF + parse work in non-preview mode.
114
+ const strippedPath = stripBasePath(basePath, pathname);
115
+ const search = sanitizePreviewLocationSearch(searchParams);
116
+ const rawSearch = searchParams.toString();
117
+ // Hash is not surfaced by App Router hooks; read it from
118
+ // window.location. Next has already scrolled/applied the
119
+ // hash by the time this effect runs.
120
+ const hash = typeof window !== 'undefined' ? window.location.hash : '';
121
+ const committedLocation = `${strippedPath}${rawSearch ? `?${rawSearch}` : ''}${hash}`;
122
+ observePreviewNavigationCommit(committedLocation);
123
+ if (!isPreviewModeLatched() || !confirmPreviewRender())
124
+ return;
125
+ recordPreviewPerfMetric(window, { name: 'next.navigate.locationCommitted' });
126
+ notifyRouterTransition({
127
+ pathname: strippedPath,
128
+ browserPathname: `${basePath.replace(/\/$/, '')}${strippedPath}`,
129
+ search,
130
+ hash,
131
+ });
132
+ };
133
+ if (typeof window === 'undefined' ||
134
+ typeof window.requestAnimationFrame !== 'function') {
135
+ // SSR / jsdom-without-rAF — invoke synchronously.
136
+ tick();
137
+ }
138
+ else {
139
+ rafId = window.requestAnimationFrame(tick);
140
+ }
141
+ return () => {
142
+ cancelled = true;
143
+ if (rafId !== null && typeof window !== 'undefined') {
144
+ window.cancelAnimationFrame(rafId);
145
+ rafId = null;
146
+ }
147
+ };
148
+ }, [pathname, searchParams, basePath]);
149
+ }
@@ -0,0 +1,94 @@
1
+ import { type PreviewScope } from '@storystick/preview/identity';
2
+ /**
3
+ * Minimal contract we need from the resolved router : a `replace`
4
+ * method that takes a same-origin absolute pathname. App Router's
5
+ * `AppRouterInstance` and Pages Router's `NextRouter` both satisfy
6
+ * this — we narrow to it explicitly so the rest of the hook stays
7
+ * router-agnostic.
8
+ *
9
+ * **ADR-101 D3** — the iframe-side adapter uses `replace`, not
10
+ * `push`, for every Studio-issued navigate envelope. Studio history
11
+ * is authoritative ; iframe history is suppressed so the editor's
12
+ * Back button steps through studio navigations (cross-doc swaps,
13
+ * route changes), never through per-keystroke pathname edits or
14
+ * variant-pathname shifts. Matches Sanity Visual Editing,
15
+ * Storyblok preview bridge, Builder.io, and Whatfix's published
16
+ * preview-bridge guidance.
17
+ *
18
+ * Customer-initiated nav (an end-user clicks an `<a>` inside the
19
+ * iframe during a content demo) does NOT go through this hook —
20
+ * the customer's framework router handles it directly. The flip
21
+ * here is scoped strictly to envelope-driven nav.
22
+ */
23
+ interface RouterReplaceable {
24
+ replace: (pathname: string) => unknown;
25
+ push: (pathname: string) => unknown;
26
+ /**
27
+ * ADR-111 — optional. App Router exposes `router.refresh()`; the
28
+ * Pages Router does not. The adapter calls this when the envelope
29
+ * carries `payload.refresh === true` (layout preview state changed)
30
+ * so the client Router Cache invalidates and `resolvePreviewDraft()`
31
+ * re-runs against the new session row. On Pages Router the field
32
+ * is undefined and the call is skipped (Pages has no cached layout
33
+ * segment to invalidate — the published-vs-draft branch lives in
34
+ * `getServerSideProps`, which re-runs on every replace).
35
+ */
36
+ refresh?: () => unknown;
37
+ }
38
+ /**
39
+ * ADR-108 D6 — options accepted alongside the App Router argument.
40
+ * Currently exposes `basePath` for `next.config.js`-configured prefix
41
+ * stripping. Future fields (locale stripping, custom serialiser, etc.)
42
+ * extend this without bumping the hook signature.
43
+ */
44
+ export interface PreviewNavOptions {
45
+ /**
46
+ * `next.config.js`-configured basePath. When set, the SDK strips
47
+ * the prefix (path-segment-boundary aware) from
48
+ * `window.location.pathname` BEFORE building the
49
+ * `storystick.preview.location` envelope.
50
+ *
51
+ * Pages Router: auto-defaults from `pagesRouter.basePath` (public
52
+ * API on `NextRouter`). Pass explicitly only to override.
53
+ *
54
+ * App Router: defaults to `''` because App Router does not expose
55
+ * basePath publicly. App Router customers using `next.config.js`
56
+ * basePath MUST pass it here.
57
+ *
58
+ * Stripping logic: segment-boundary aware. `'/admin'` strips
59
+ * `/admin/page` → `/page` but NOT `/administrator` (no leading
60
+ * `/strator` garbage). Trailing slash normalised: `'/admin/'` and
61
+ * `'/admin'` behave identically.
62
+ */
63
+ basePath?: string;
64
+ /**
65
+ * Internal runtime hook used to publish the exact Studio-selected
66
+ * perspective before the framework starts rendering the destination.
67
+ * Framework integrations may omit it; URL scope remains authoritative.
68
+ */
69
+ onScopeChange?: (scope: PreviewScope) => void;
70
+ }
71
+ /**
72
+ * ADR-108 D6.bis — strip a configured basePath from a browser-form
73
+ * pathname. Pure function exported for tests.
74
+ *
75
+ * stripBasePath('/admin', '/admin/page') → '/page'
76
+ * stripBasePath('/admin/', '/admin/page') → '/page' (trailing slash normalised)
77
+ * stripBasePath('/admin', '/admin') → '/' (exact match → root)
78
+ * stripBasePath('/admin', '/administrator') → '/administrator' (segment-boundary)
79
+ * stripBasePath('', '/page') → '/page' (no-op)
80
+ */
81
+ export declare function stripBasePath(basePath: string, pathname: string): string;
82
+ /**
83
+ * Wire `@storystick/preview`'s navigate handler to a Next router.
84
+ *
85
+ * @param appRouter — App Router instance from `next/navigation`'s
86
+ * `useRouter()`. App Router consumers pass it explicitly (see
87
+ * module JSDoc). Optional — when omitted, the hook falls back to
88
+ * the Pages Router instance auto-detected via `next/compat/router`.
89
+ *
90
+ * No-op outside preview mode. On unmount, restores a surviving adapter or
91
+ * the default no-op handler, leaving no stale registration behind.
92
+ */
93
+ export declare function useStorystickPreviewNavigation(appRouter?: RouterReplaceable | null, options?: PreviewNavOptions): void;
94
+ export {};