@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.
- package/LICENSE +112 -0
- package/PUBLIC-API.md +31 -0
- package/README.md +522 -2
- package/dist/.source-hash +1 -0
- package/dist/cache-tags.d.ts +2 -0
- package/dist/cache-tags.js +12 -0
- package/dist/clean.d.ts +1 -0
- package/dist/clean.js +1 -0
- package/dist/code-version.d.ts +29 -0
- package/dist/code-version.js +48 -0
- package/dist/file.d.ts +1 -0
- package/dist/file.js +1 -0
- package/dist/i18n-seo.d.ts +1 -0
- package/dist/i18n-seo.js +1 -0
- package/dist/image.d.ts +11 -0
- package/dist/image.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/index.react-server.d.ts +3 -0
- package/dist/index.react-server.js +12 -0
- package/dist/link.d.ts +10 -0
- package/dist/link.js +8 -0
- package/dist/preview/index.d.ts +29 -0
- package/dist/preview/index.js +24 -0
- package/dist/preview/internal.d.ts +61 -0
- package/dist/preview/internal.js +97 -0
- package/dist/preview/preview-request-identity.d.ts +4 -0
- package/dist/preview/preview-request-identity.js +63 -0
- package/dist/preview/provenance-config.d.ts +42 -0
- package/dist/preview/provenance-config.js +132 -0
- package/dist/preview/provenance-frame-builder.d.ts +6 -0
- package/dist/preview/provenance-frame-builder.js +10 -0
- package/dist/preview/provenance-response.d.ts +5 -0
- package/dist/preview/provenance-response.js +9 -0
- package/dist/preview/provenance-runtime-adapter.d.ts +9 -0
- package/dist/preview/provenance-runtime-adapter.js +42 -0
- package/dist/preview/render-frame-provenance.d.ts +23 -0
- package/dist/preview/render-frame-provenance.js +47 -0
- package/dist/preview/route-authoring.d.ts +3 -0
- package/dist/preview/route-authoring.js +80 -0
- package/dist/preview/route-context.d.ts +3 -0
- package/dist/preview/route-context.js +38 -0
- package/dist/preview/route-disable.d.ts +7 -0
- package/dist/preview/route-disable.js +63 -0
- package/dist/preview/route-document-provenance.d.ts +23 -0
- package/dist/preview/route-document-provenance.js +46 -0
- package/dist/preview/route-errors.d.ts +1 -0
- package/dist/preview/route-errors.js +1 -0
- package/dist/preview/route-handler.d.ts +36 -0
- package/dist/preview/route-handler.js +139 -0
- package/dist/preview/route-initial.d.ts +9 -0
- package/dist/preview/route-initial.js +141 -0
- package/dist/preview/route-provenance-runtime.d.ts +34 -0
- package/dist/preview/route-provenance-runtime.js +90 -0
- package/dist/preview/route-token.d.ts +6 -0
- package/dist/preview/route-token.js +46 -0
- package/dist/preview/server-preview-internals.d.ts +3 -0
- package/dist/preview/server-preview-internals.js +3 -0
- package/dist/preview/slice-locale.d.ts +5 -0
- package/dist/preview/slice-locale.js +15 -0
- package/dist/preview-error-surface.d.ts +7 -0
- package/dist/preview-error-surface.js +24 -0
- package/dist/preview-exit.d.ts +7 -0
- package/dist/preview-exit.js +71 -0
- package/dist/preview-metadata.d.ts +15 -0
- package/dist/preview-metadata.js +13 -0
- package/dist/preview-mode-latch.d.ts +133 -0
- package/dist/preview-mode-latch.js +231 -0
- package/dist/preview-nav-location.d.ts +12 -0
- package/dist/preview-nav-location.js +149 -0
- package/dist/preview-nav.d.ts +94 -0
- package/dist/preview-nav.js +523 -0
- package/dist/preview-refresh-coordinator.d.ts +26 -0
- package/dist/preview-refresh-coordinator.js +93 -0
- package/dist/preview-render-error.d.ts +11 -0
- package/dist/preview-render-error.js +36 -0
- package/dist/preview-render-recovery.d.ts +9 -0
- package/dist/preview-render-recovery.js +42 -0
- package/dist/preview-session-recovery.d.ts +1 -0
- package/dist/preview-session-recovery.js +1 -0
- package/dist/preview-standalone-host.d.ts +3 -0
- package/dist/preview-standalone-host.js +55 -0
- package/dist/preview-text.d.ts +2 -0
- package/dist/preview-text.js +1 -0
- package/dist/preview-view-bootstrap.d.ts +5 -0
- package/dist/preview-view-bootstrap.js +13 -0
- package/dist/public-delivery.d.ts +6 -0
- package/dist/public-delivery.js +7 -0
- package/dist/query-fetch-options.d.ts +7 -0
- package/dist/query-fetch-options.js +193 -0
- package/dist/query.d.ts +2 -0
- package/dist/query.js +1 -0
- package/dist/rendering.d.ts +33 -0
- package/dist/rendering.js +42 -0
- package/dist/revalidate.d.ts +12 -0
- package/dist/revalidate.js +33 -0
- package/dist/rich-text.d.ts +2 -0
- package/dist/rich-text.js +1 -0
- package/dist/server-runtime.d.ts +208 -0
- package/dist/server-runtime.js +1584 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +6 -0
- package/dist/slice-zone.d.ts +2 -0
- package/dist/slice-zone.js +1 -0
- package/dist/storystick-component-types.d.ts +26 -0
- package/dist/storystick-component-types.js +1 -0
- package/dist/storystick-component.d.ts +10 -0
- package/dist/storystick-component.js +45 -0
- package/dist/storystick-error-recovery.d.ts +12 -0
- package/dist/storystick-error-recovery.js +11 -0
- package/dist/storystick-preview-runtime.d.ts +30 -0
- package/dist/storystick-preview-runtime.js +561 -0
- package/dist/storystick.d.ts +24 -0
- package/dist/storystick.js +23 -0
- package/dist/testing.d.ts +9 -0
- package/dist/testing.js +40 -0
- package/dist/video.d.ts +1 -0
- package/dist/video.js +1 -0
- package/package.json +128 -3
- package/recipes/README.md +91 -0
- 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 {};
|