hono-preact 0.3.0 → 0.5.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.
@@ -1,18 +1,419 @@
1
- import { flushSync } from 'preact/compat';
2
- const subs = new Set();
3
- export function __dispatchRouteChange(to, from) {
4
- for (const cb of subs)
5
- cb(to, from);
6
- if (typeof document === 'undefined')
7
- return;
8
- const startViewTransition = document.startViewTransition;
9
- if (typeof startViewTransition !== 'function')
10
- return;
11
- startViewTransition.call(document, () => flushSync(() => { }));
1
+ import { options } from 'preact';
2
+ import { ViewTransitionEvent, } from './view-transition-event.js';
3
+ import { getNavDirection, onNavigation } from './history-shim.js';
4
+ const phaseSubs = {
5
+ beforeTransition: new Set(),
6
+ beforeSwap: new Set(),
7
+ afterSwap: new Set(),
8
+ afterTransition: new Set(),
9
+ };
10
+ const legacySubs = new Set();
11
+ export function __subscribePhase(phase, sub) {
12
+ phaseSubs[phase].add(sub);
13
+ return () => {
14
+ phaseSubs[phase].delete(sub);
15
+ };
12
16
  }
13
17
  export function __subscribeRouteChange(sub) {
14
- subs.add(sub);
18
+ legacySubs.add(sub);
15
19
  return () => {
16
- subs.delete(sub);
20
+ legacySubs.delete(sub);
17
21
  };
18
22
  }
23
+ function fireLegacy(to, from) {
24
+ for (const sub of legacySubs)
25
+ sub(to, from);
26
+ }
27
+ function getStartViewTransition() {
28
+ if (typeof document === 'undefined')
29
+ return undefined;
30
+ const fn = document.startViewTransition;
31
+ return typeof fn === 'function' ? fn.bind(document) : undefined;
32
+ }
33
+ function currentPath() {
34
+ return typeof location !== 'undefined'
35
+ ? location.pathname + location.search
36
+ : '';
37
+ }
38
+ // `loadingDepth`: how many Routers are mid-suspense (via onLoadStart/onLoadEnd).
39
+ // Read right after a navigation commits to tell whether the route suspended (a
40
+ // cold navigation), so the transition can wait for the suspended content.
41
+ let loadingDepth = 0;
42
+ export function __noteLoadStart() {
43
+ loadingDepth++;
44
+ }
45
+ export function __noteLoadEnd() {
46
+ loadingDepth = Math.max(0, loadingDepth - 1);
47
+ }
48
+ // The path the app is currently on (the previous navigation's `to`); seeds
49
+ // `from` for the first navigation. A server render leaves it undefined.
50
+ let lastPath = typeof location !== 'undefined'
51
+ ? location.pathname + location.search
52
+ : undefined;
53
+ // Cold-navigation state: a navigation's transition holds until the suspending
54
+ // route's content flushes have all run (see the scheduler below).
55
+ let coldTimeout = null;
56
+ // Bumped per navigation so a superseded transition's (async) callback bows out.
57
+ let navGen = 0;
58
+ // Cap on how long a navigation holds the old snapshot waiting for a suspending
59
+ // route's content. Past it the navigation completes without finishing the
60
+ // transition rather than freezing the page on a slow/stalled load.
61
+ const COLD_COMMIT_TIMEOUT_MS = 500;
62
+ // Extra grace, after the shell is ready, to let a morph partner that loads with
63
+ // the route's DATA (behind inner Suspense, which doesn't move loadingDepth)
64
+ // appear in the new snapshot before the transition captures it.
65
+ const MORPH_PARTNER_GRACE_MS = 150;
66
+ /** @internal Test-only reset for coordinator state. */
67
+ export function __resetTransitionStateForTesting() {
68
+ loadingDepth = 0;
69
+ navGen = 0;
70
+ lastPath =
71
+ typeof location !== 'undefined'
72
+ ? location.pathname + location.search
73
+ : undefined;
74
+ coldRouteSignal = null;
75
+ if (coldTimeout !== null) {
76
+ clearTimeout(coldTimeout);
77
+ coldTimeout = null;
78
+ }
79
+ transitionActive = false;
80
+ // Uninstall the render scheduler (restoring Preact's debounceRendering) so
81
+ // each test can install it fresh.
82
+ if (schedulerInstalled) {
83
+ options.debounceRendering = prevDebounce;
84
+ schedulerInstalled = false;
85
+ prevDebounce = undefined;
86
+ if (unsubscribeNav) {
87
+ unsubscribeNav();
88
+ unsubscribeNav = null;
89
+ }
90
+ }
91
+ }
92
+ function fireAfterSwap(event) {
93
+ for (const sub of phaseSubs.afterSwap)
94
+ sub(event);
95
+ // Legacy subscribers fire at the afterSwap slot: after the DOM swap, before
96
+ // the browser begins animating the new frame.
97
+ fireLegacy(event.to, event.from);
98
+ }
99
+ function fireAfterTransition(event, reason) {
100
+ if (reason !== undefined)
101
+ event.reason = reason;
102
+ for (const sub of phaseSubs.afterTransition)
103
+ sub(event);
104
+ }
105
+ function applyTypes(transition, types) {
106
+ const vtTypes = transition.types;
107
+ if (vtTypes && typeof vtTypes.add === 'function') {
108
+ for (const t of types)
109
+ vtTypes.add(t);
110
+ }
111
+ }
112
+ function skipTransition(transition) {
113
+ const t = transition;
114
+ if (typeof t.skipTransition === 'function')
115
+ t.skipTransition();
116
+ }
117
+ // Build the event for a navigation that has just committed: `to`/`direction`
118
+ // are only correct after the commit (pushState updates the history shim), so
119
+ // this is always called post-commit. Advances `lastPath`.
120
+ function buildEvent(from) {
121
+ ensureDefaultTypes();
122
+ const to = currentPath();
123
+ lastPath = to;
124
+ const direction = getNavDirection();
125
+ const event = new ViewTransitionEvent({ to, from, direction });
126
+ for (const sub of phaseSubs.beforeTransition)
127
+ sub(event);
128
+ return event;
129
+ }
130
+ let schedulerInstalled = false;
131
+ let lastHref = '';
132
+ let prevDebounce;
133
+ let unsubscribeNav = null;
134
+ // True while a navigation's transition is in flight (its callback is pending or
135
+ // awaiting cold content). Lets the navigation observer abandon it.
136
+ let transitionActive = false;
137
+ // Set while a cold navigation's transition awaits a content flush; the next
138
+ // same-URL flush hands its `process` here (or `null` on supersede/timeout) so
139
+ // the transition can run it inside itself.
140
+ let coldRouteSignal = null;
141
+ function defaultSchedule(process) {
142
+ if (prevDebounce)
143
+ prevDebounce(process);
144
+ else
145
+ Promise.resolve().then(process);
146
+ }
147
+ // Fired (via the history shim) at navigation time, before the re-render. If a
148
+ // transition from a previous navigation is still in flight, abandon it here:
149
+ // Preact may coalesce the new navigation's render into the in-flight one, so
150
+ // scheduleRender never sees it and its own supersede branch can't fire.
151
+ function onNavObserved() {
152
+ if (!transitionActive && !coldRouteSignal)
153
+ return;
154
+ navGen++; // the in-flight callback bows out at its next navGen check
155
+ transitionActive = false;
156
+ if (coldRouteSignal) {
157
+ const resolve = coldRouteSignal;
158
+ coldRouteSignal = null;
159
+ if (coldTimeout !== null) {
160
+ clearTimeout(coldTimeout);
161
+ coldTimeout = null;
162
+ }
163
+ resolve(null);
164
+ }
165
+ }
166
+ /**
167
+ * @internal Install the view-transition render scheduler (client only).
168
+ *
169
+ * Takes ownership of `options.debounceRendering`: it captures the previous value
170
+ * as `prevDebounce` (delegated to for every non-navigation flush) and installs
171
+ * `scheduleRender` in its place. This assumes nothing else permanently overrides
172
+ * `options.debounceRendering` afterward. `preact/compat`'s `flushSync` swaps it
173
+ * temporarily and restores it, so that composes fine; a second permanent
174
+ * override would shadow this scheduler (the install is idempotent, so calling it
175
+ * twice is a no-op, not a double-install). Reversed by
176
+ * `__resetTransitionStateForTesting`.
177
+ */
178
+ export function installNavTransitionScheduler() {
179
+ if (schedulerInstalled)
180
+ return;
181
+ if (typeof document === 'undefined' || typeof location === 'undefined')
182
+ return;
183
+ schedulerInstalled = true;
184
+ lastHref = location.href;
185
+ prevDebounce = options.debounceRendering;
186
+ options.debounceRendering = scheduleRender;
187
+ unsubscribeNav = onNavigation(onNavObserved);
188
+ }
189
+ function scheduleRender(process) {
190
+ const href = location.href;
191
+ const navigated = href !== lastHref;
192
+ // The content flush for an in-flight cold navigation (same URL): hand it back
193
+ // to that transition so it lands in the new snapshot.
194
+ if (coldRouteSignal && !navigated) {
195
+ const resolve = coldRouteSignal;
196
+ coldRouteSignal = null;
197
+ if (coldTimeout !== null) {
198
+ clearTimeout(coldTimeout);
199
+ coldTimeout = null;
200
+ }
201
+ resolve(process); // the transition's callback runs `process()` itself
202
+ return;
203
+ }
204
+ // A new navigation arrived while a cold one was still loading: abandon it.
205
+ if (coldRouteSignal && navigated) {
206
+ navGen++;
207
+ const resolve = coldRouteSignal;
208
+ coldRouteSignal = null;
209
+ if (coldTimeout !== null) {
210
+ clearTimeout(coldTimeout);
211
+ coldTimeout = null;
212
+ }
213
+ resolve(null);
214
+ }
215
+ if (navigated) {
216
+ // Reset the load counter at the start of a navigation. A previous route's
217
+ // loads are abandoned by a new navigation, and preact-iso fires onLoadStart
218
+ // without a matching onLoadEnd when a still-suspended Router unmounts (it
219
+ // emits onLoadEnd only on a committed render, not on unmount). Left alone,
220
+ // that leaked depth would make this nav (and later ones) look perpetually
221
+ // cold and burn the cold-load timeout. This nav re-increments it as its own
222
+ // route suspends.
223
+ loadingDepth = 0;
224
+ }
225
+ lastHref = href;
226
+ const start = navigated ? getStartViewTransition() : undefined;
227
+ if (!start) {
228
+ defaultSchedule(process);
229
+ return;
230
+ }
231
+ runNavTransition(process, start);
232
+ }
233
+ // Wait for the next content flush of an in-flight cold navigation (routed here
234
+ // by scheduleRender), or null on timeout/supersede.
235
+ function waitForColdFlush(myGen, timeoutMs) {
236
+ return new Promise((resolve) => {
237
+ coldRouteSignal = resolve;
238
+ coldTimeout = setTimeout(() => {
239
+ if (navGen === myGen && coldRouteSignal) {
240
+ coldRouteSignal = null;
241
+ coldTimeout = null;
242
+ resolve(null);
243
+ }
244
+ }, timeoutMs);
245
+ });
246
+ }
247
+ // Elements carrying an inline `view-transition-name`. The attribute selector
248
+ // lets the browser filter natively instead of walking every node in JS — this
249
+ // runs on the frozen hot path (inside the transition callback, possibly once per
250
+ // grace tick), so the candidate set should be as small as possible. The selector
251
+ // is a substring match on the serialized `style` attribute, so we still confirm
252
+ // each match by reading the resolved property below.
253
+ function queryVtNamedElements() {
254
+ if (typeof document === 'undefined' || !document.querySelectorAll)
255
+ return [];
256
+ return Array.from(document.querySelectorAll('[style*="view-transition-name"]'));
257
+ }
258
+ // The view-transition-names currently applied in the document (inline styles).
259
+ function collectVtNames() {
260
+ const names = new Set();
261
+ for (const el of queryVtNamedElements()) {
262
+ const n = el.style?.getPropertyValue?.('view-transition-name');
263
+ if (n)
264
+ names.add(n);
265
+ }
266
+ return names;
267
+ }
268
+ // Whether any currently-applied view-transition-name was also in `oldNames` —
269
+ // i.e. a morph pair (same name old + new) is present.
270
+ function hasMorphPartner(oldNames) {
271
+ if (oldNames.size === 0)
272
+ return false;
273
+ for (const el of queryVtNamedElements()) {
274
+ const n = el.style?.getPropertyValue?.('view-transition-name');
275
+ if (n && oldNames.has(n))
276
+ return true;
277
+ }
278
+ return false;
279
+ }
280
+ function runNavTransition(process, start) {
281
+ const from = lastPath;
282
+ // The names present in the outgoing route — used to know when a morph partner
283
+ // has appeared in the new route (see the grace wait below).
284
+ const oldNames = collectVtNames();
285
+ const myGen = ++navGen;
286
+ transitionActive = true;
287
+ let transition;
288
+ let event;
289
+ try {
290
+ transition = start(async () => {
291
+ // The old snapshot has been captured. Flush the navigation render.
292
+ process();
293
+ if (navGen !== myGen)
294
+ return;
295
+ event = buildEvent(from);
296
+ event.transition = transition;
297
+ applyTypes(transition, event.types);
298
+ if (event._skipped) {
299
+ skipTransition(transition);
300
+ }
301
+ else {
302
+ for (const sub of phaseSubs.beforeSwap)
303
+ sub(event);
304
+ // Cold: the route suspended. Keep routing its content flushes into the
305
+ // transition until every route module has loaded (loadingDepth back to
306
+ // 0) — the page-level shell.
307
+ while (loadingDepth > 0) {
308
+ const contentProcess = await waitForColdFlush(myGen, COLD_COMMIT_TIMEOUT_MS);
309
+ if (navGen !== myGen)
310
+ return;
311
+ if (!contentProcess)
312
+ break; // timed out waiting
313
+ contentProcess();
314
+ }
315
+ // If the outgoing route had named elements but none has a partner in the
316
+ // new shell yet, the partner may load with the route's DATA (behind
317
+ // inner Suspense, which doesn't move loadingDepth — e.g. a list whose
318
+ // items come from a loader). Wait briefly for it so the morph can pair.
319
+ if (oldNames.size > 0 && !hasMorphPartner(oldNames)) {
320
+ while (!hasMorphPartner(oldNames)) {
321
+ const contentProcess = await waitForColdFlush(myGen, MORPH_PARTNER_GRACE_MS);
322
+ if (navGen !== myGen)
323
+ return;
324
+ if (!contentProcess)
325
+ break; // grace expired — capture as-is
326
+ contentProcess();
327
+ }
328
+ }
329
+ }
330
+ if (navGen !== myGen)
331
+ return;
332
+ fireAfterSwap(event);
333
+ transitionActive = false; // reached only when still current
334
+ });
335
+ }
336
+ catch {
337
+ // Non-conformant startViewTransition: just flush and fire the post phases.
338
+ transitionActive = false;
339
+ process();
340
+ const ev = buildEvent(from);
341
+ fireAfterSwap(ev);
342
+ fireAfterTransition(ev, 'unsupported');
343
+ return;
344
+ }
345
+ transition.finished.then(() => {
346
+ if (event)
347
+ fireAfterTransition(event, event._skipped ? 'skipped' : undefined);
348
+ }, () => {
349
+ if (event)
350
+ fireAfterTransition(event, 'aborted');
351
+ });
352
+ }
353
+ // Synchronous route-change dispatch for an explicit `to`/`from`: fires
354
+ // `beforeTransition` and runs a transition that wraps a no-op swap (the route is
355
+ // assumed already on screen), firing the post-swap phases and applying types.
356
+ // Production navigations are driven by the scheduler (installNavTransition
357
+ // scheduler); this drives the same phase/type/lifecycle machinery directly for
358
+ // callers that change the route outside the normal navigation flow (and in unit
359
+ // tests).
360
+ export function __dispatchRouteChange(to, from) {
361
+ ensureDefaultTypes();
362
+ const event = new ViewTransitionEvent({
363
+ to,
364
+ from,
365
+ direction: getNavDirection(),
366
+ });
367
+ for (const sub of phaseSubs.beforeTransition)
368
+ sub(event);
369
+ const start = getStartViewTransition();
370
+ if (!start || event._skipped) {
371
+ // No transition runs, so `beforeSwap` (which precedes a real swap) is
372
+ // skipped; the post-swap phases still fire.
373
+ fireAfterSwap(event);
374
+ fireAfterTransition(event, event._skipped ? 'skipped' : 'unsupported');
375
+ return;
376
+ }
377
+ let transition;
378
+ try {
379
+ transition = start(() => { });
380
+ }
381
+ catch {
382
+ fireAfterSwap(event);
383
+ fireAfterTransition(event, 'unsupported');
384
+ return;
385
+ }
386
+ event.transition = transition;
387
+ applyTypes(transition, event.types);
388
+ for (const sub of phaseSubs.beforeSwap)
389
+ sub(event);
390
+ fireAfterSwap(event);
391
+ transition.finished.then(() => fireAfterTransition(event), () => fireAfterTransition(event, 'aborted'));
392
+ }
393
+ let defaultTypesInstalled = false;
394
+ let firstNavSeen = false;
395
+ let defaultTypeUnsubscriber = null;
396
+ function ensureDefaultTypes() {
397
+ if (defaultTypesInstalled)
398
+ return;
399
+ defaultTypesInstalled = true;
400
+ defaultTypeUnsubscriber = __subscribePhase('beforeTransition', (event) => {
401
+ if (!firstNavSeen) {
402
+ event.types.push('nav-initial');
403
+ firstNavSeen = true;
404
+ }
405
+ else {
406
+ event.types.push(`nav-${event.direction}`);
407
+ }
408
+ event.types.push('nav-same-origin');
409
+ });
410
+ }
411
+ /** @internal Test-only reset for default-types installer. */
412
+ export function resetDefaultTypesForTesting() {
413
+ if (defaultTypeUnsubscriber) {
414
+ defaultTypeUnsubscriber();
415
+ }
416
+ defaultTypesInstalled = false;
417
+ firstNavSeen = false;
418
+ defaultTypeUnsubscriber = null;
419
+ }
@@ -0,0 +1,11 @@
1
+ import { type ComponentChildren, type VNode } from 'preact';
2
+ type Props = Record<string, unknown>;
3
+ export type UseRenderRender = VNode | string | ((props: Props) => VNode) | undefined;
4
+ interface UseRenderOptions {
5
+ render?: UseRenderRender;
6
+ defaultTag: string;
7
+ props: Props;
8
+ children?: ComponentChildren;
9
+ }
10
+ export declare function useRender(opts: UseRenderOptions): VNode;
11
+ export {};
@@ -0,0 +1,47 @@
1
+ import { cloneElement, h, } from 'preact';
2
+ import { mergeRefs } from './merge-refs.js';
3
+ function joinClass(a, b) {
4
+ const parts = [];
5
+ if (typeof a === 'string' && a.length > 0)
6
+ parts.push(a);
7
+ if (typeof b === 'string' && b.length > 0)
8
+ parts.push(b);
9
+ if (parts.length === 0)
10
+ return undefined;
11
+ return parts.join(' ');
12
+ }
13
+ function mergeProps(user, framework) {
14
+ const out = { ...user };
15
+ for (const key of Object.keys(framework)) {
16
+ if (key === 'class' || key === 'className') {
17
+ const userClass = (user.class ?? user.className);
18
+ const merged = joinClass(userClass, framework[key]);
19
+ if (merged !== undefined)
20
+ out.class = merged;
21
+ delete out.className;
22
+ }
23
+ else if (key === 'ref') {
24
+ out.ref = mergeRefs(user.ref, framework.ref);
25
+ }
26
+ else {
27
+ out[key] = framework[key];
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+ export function useRender(opts) {
33
+ const { render, defaultTag, props, children } = opts;
34
+ if (typeof render === 'function') {
35
+ return render(mergeProps({}, props));
36
+ }
37
+ if (render && typeof render === 'object' && 'type' in render) {
38
+ const merged = mergeProps((render.props ?? {}), props);
39
+ const mergedChildren = children !== undefined
40
+ ? children
41
+ : (render.props?.children ??
42
+ null);
43
+ return cloneElement(render, merged, mergedChildren);
44
+ }
45
+ const tag = typeof render === 'string' ? render : defaultTag;
46
+ return h(tag, props, children);
47
+ }
@@ -0,0 +1,23 @@
1
+ export type NavDirection = 'initial' | 'push' | 'replace' | 'back' | 'forward';
2
+ export type ViewTransitionReason = 'skipped' | 'unsupported' | 'aborted';
3
+ interface ViewTransitionEventInit {
4
+ to: string;
5
+ from: string | undefined;
6
+ direction: NavDirection;
7
+ }
8
+ export declare class ViewTransitionEvent {
9
+ readonly to: string;
10
+ readonly from: string | undefined;
11
+ readonly direction: NavDirection;
12
+ readonly types: string[];
13
+ transition: ViewTransition | null;
14
+ reason: ViewTransitionReason | undefined;
15
+ /** @internal */
16
+ _skipped: boolean;
17
+ private readonly stash;
18
+ constructor(init: ViewTransitionEventInit);
19
+ skip(): void;
20
+ set(key: unknown, value: unknown): void;
21
+ get<T = unknown>(key: unknown): T | undefined;
22
+ }
23
+ export {};
@@ -0,0 +1,25 @@
1
+ export class ViewTransitionEvent {
2
+ to;
3
+ from;
4
+ direction;
5
+ types = [];
6
+ transition = null;
7
+ reason = undefined;
8
+ /** @internal */
9
+ _skipped = false;
10
+ stash = new Map();
11
+ constructor(init) {
12
+ this.to = init.to;
13
+ this.from = init.from;
14
+ this.direction = init.direction;
15
+ }
16
+ skip() {
17
+ this._skipped = true;
18
+ }
19
+ set(key, value) {
20
+ this.stash.set(key, value);
21
+ }
22
+ get(key) {
23
+ return this.stash.get(key);
24
+ }
25
+ }
@@ -11,7 +11,12 @@ export { runRequestScope, getRequestStore, captureRequestScope, getActionResultS
11
11
  export { default as wrapPromise } from './internal/wrap-promise.js';
12
12
  export { HonoRequestContext } from './internal/contexts.js';
13
13
  export { PageMiddlewareHost } from './internal/page-middleware-host.js';
14
- export { __dispatchRouteChange, __subscribeRouteChange, } from './internal/route-change.js';
14
+ export { installNavTransitionScheduler, __subscribeRouteChange, } from './internal/route-change.js';
15
+ export { installHistoryShim, getNavDirection, } from './internal/history-shim.js';
16
+ export { __subscribePhase, type PhaseName } from './internal/route-change.js';
17
+ export { ViewTransitionEvent, type NavDirection, type ViewTransitionReason, } from './internal/view-transition-event.js';
18
+ export { useRender, type UseRenderRender } from './internal/use-render.js';
19
+ export { mergeRefs } from './internal/merge-refs.js';
15
20
  export { installStreamRegistry, subscribeToLoaderStream, } from './internal/stream-registry.js';
16
21
  export { registerServerStreamingLoader, takeServerStreamingLoaders, } from './internal/streaming-ssr.js';
17
22
  export type { ServerLoaderStream } from './internal/streaming-ssr.js';
@@ -37,7 +37,12 @@ export { runRequestScope, getRequestStore, captureRequestScope, getActionResultS
37
37
  export { default as wrapPromise } from './internal/wrap-promise.js';
38
38
  export { HonoRequestContext } from './internal/contexts.js';
39
39
  export { PageMiddlewareHost } from './internal/page-middleware-host.js';
40
- export { __dispatchRouteChange, __subscribeRouteChange, } from './internal/route-change.js';
40
+ export { installNavTransitionScheduler, __subscribeRouteChange, } from './internal/route-change.js';
41
+ export { installHistoryShim, getNavDirection, } from './internal/history-shim.js';
42
+ export { __subscribePhase } from './internal/route-change.js';
43
+ export { ViewTransitionEvent, } from './internal/view-transition-event.js';
44
+ export { useRender } from './internal/use-render.js';
45
+ export { mergeRefs } from './internal/merge-refs.js';
41
46
  export { installStreamRegistry, subscribeToLoaderStream, } from './internal/stream-registry.js';
42
47
  export { registerServerStreamingLoader, takeServerStreamingLoaders, } from './internal/streaming-ssr.js';
43
48
  export { beginSubmit, endSubmit, isPending, subscribe as subscribeFormSubmit, } from './internal/form-submit-store.js';
@@ -0,0 +1,14 @@
1
+ import type { ComponentChildren, VNode } from 'preact';
2
+ export interface PersistProps {
3
+ id: string;
4
+ viewTransitionName?: string;
5
+ children?: ComponentChildren;
6
+ }
7
+ export declare function Persist(props: PersistProps): VNode;
8
+ export declare namespace Persist {
9
+ var displayName: string;
10
+ }
11
+ export declare function PersistHost(): VNode;
12
+ export declare namespace PersistHost {
13
+ var displayName: string;
14
+ }
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx } from "preact/jsx-runtime";
2
+ import { Fragment, h } from 'preact';
3
+ import { useLayoutEffect, useReducer } from 'preact/hooks';
4
+ import { __persistRegistryWrite, __persistRegistryRead, __persistRegistrySubscribe, } from './internal/persist-registry.js';
5
+ import { useViewTransitionName } from './view-transition-name.js';
6
+ import { isBrowser } from './is-browser.js';
7
+ export function Persist(props) {
8
+ const browser = isBrowser();
9
+ // Hook is called unconditionally. The effect short-circuits on the server,
10
+ // so SSR's render output (children inline) remains the only side effect.
11
+ // No deps array: runs after every render so children/viewTransitionName
12
+ // updates flow through without stale captures.
13
+ useLayoutEffect(() => {
14
+ if (!browser)
15
+ return;
16
+ const entry = {
17
+ children: props.children,
18
+ viewTransitionName: props.viewTransitionName,
19
+ };
20
+ __persistRegistryWrite(props.id, entry);
21
+ // Intentionally no cleanup: Persist does NOT clear the registry on unmount.
22
+ // Keeping the last-known children lets PersistHost continue to render
23
+ // across route changes where Persist temporarily disappears.
24
+ });
25
+ // SSR renders children inline so first paint matches steady state;
26
+ // the client renders nothing inline because PersistHost owns the DOM.
27
+ return browser ? h(Fragment, null) : h(Fragment, null, props.children);
28
+ }
29
+ Persist.displayName = 'Persist';
30
+ function PersistSlot(props) {
31
+ const ref = useViewTransitionName(props.entry.viewTransitionName);
32
+ return (_jsx("div", { "data-hp-persist-slot": props.id, ref: ref, children: props.entry.children }));
33
+ }
34
+ PersistSlot.displayName = 'PersistSlot';
35
+ export function PersistHost() {
36
+ // useReducer instead of useState: guarantees a re-render on each dispatch
37
+ // even if an intermediate render has already drained the "new" state.
38
+ // This matters for the ordering race: Persist's useLayoutEffect may run
39
+ // either before or after PersistHost's. Using useReducer ensures the
40
+ // forced tick after subscribe always queues a fresh render regardless of
41
+ // React/Preact batching.
42
+ const [, forceUpdate] = useReducer((x) => x + 1, 0);
43
+ // useLayoutEffect (not useEffect) so the subscription is in place before
44
+ // sibling effects run. The immediate forceUpdate after subscribe re-reads
45
+ // the registry to catch any Persist sibling whose useLayoutEffect already
46
+ // wrote between PersistHost's render and this subscribe call (sibling order
47
+ // is render order, but effect order can differ per host).
48
+ useLayoutEffect(() => {
49
+ const unsub = __persistRegistrySubscribe(() => forceUpdate(undefined));
50
+ forceUpdate(undefined);
51
+ return unsub;
52
+ }, []);
53
+ const map = __persistRegistryRead();
54
+ return (_jsx(Fragment, { children: Array.from(map.entries()).map(([id, entry]) => (_jsx(PersistSlot, { id: id, entry: entry }, id))) }));
55
+ }
56
+ PersistHost.displayName = 'PersistHost';
@@ -0,0 +1,9 @@
1
+ import type { ViewTransitionEvent } from './internal/view-transition-event.js';
2
+ export type ViewTransitionPhaseCallback = (event: ViewTransitionEvent) => void | Promise<void>;
3
+ export interface ViewTransitionLifecycle {
4
+ onBeforeTransition?: ViewTransitionPhaseCallback;
5
+ onBeforeSwap?: ViewTransitionPhaseCallback;
6
+ onAfterSwap?: ViewTransitionPhaseCallback;
7
+ onAfterTransition?: ViewTransitionPhaseCallback;
8
+ }
9
+ export declare function useViewTransitionLifecycle(lifecycle: ViewTransitionLifecycle): void;
@@ -0,0 +1,18 @@
1
+ import { useEffect, useRef } from 'preact/hooks';
2
+ import { __subscribePhase } from './internal/route-change.js';
3
+ export function useViewTransitionLifecycle(lifecycle) {
4
+ const ref = useRef(lifecycle);
5
+ ref.current = lifecycle;
6
+ useEffect(() => {
7
+ const unsubs = [
8
+ __subscribePhase('beforeTransition', (e) => ref.current.onBeforeTransition?.(e)),
9
+ __subscribePhase('beforeSwap', (e) => ref.current.onBeforeSwap?.(e)),
10
+ __subscribePhase('afterSwap', (e) => ref.current.onAfterSwap?.(e)),
11
+ __subscribePhase('afterTransition', (e) => ref.current.onAfterTransition?.(e)),
12
+ ];
13
+ return () => {
14
+ for (const u of unsubs)
15
+ u();
16
+ };
17
+ }, []);
18
+ }