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.
- package/dist/iso/define-routes.d.ts +0 -1
- package/dist/iso/define-routes.js +10 -3
- package/dist/iso/form.js +6 -0
- package/dist/iso/index.d.ts +6 -0
- package/dist/iso/index.js +9 -0
- package/dist/iso/internal/history-shim.d.ts +20 -0
- package/dist/iso/internal/history-shim.js +110 -0
- package/dist/iso/internal/merge-refs.d.ts +4 -0
- package/dist/iso/internal/merge-refs.js +14 -0
- package/dist/iso/internal/page-middleware-host.js +148 -45
- package/dist/iso/internal/persist-registry.d.ts +10 -0
- package/dist/iso/internal/persist-registry.js +24 -0
- package/dist/iso/internal/route-change.d.ts +25 -2
- package/dist/iso/internal/route-change.js +414 -13
- package/dist/iso/internal/use-render.d.ts +11 -0
- package/dist/iso/internal/use-render.js +47 -0
- package/dist/iso/internal/view-transition-event.d.ts +23 -0
- package/dist/iso/internal/view-transition-event.js +25 -0
- package/dist/iso/internal.d.ts +6 -1
- package/dist/iso/internal.js +6 -1
- package/dist/iso/persist.d.ts +14 -0
- package/dist/iso/persist.js +56 -0
- package/dist/iso/view-transition-lifecycle.d.ts +9 -0
- package/dist/iso/view-transition-lifecycle.js +18 -0
- package/dist/iso/view-transition-name.d.ts +17 -0
- package/dist/iso/view-transition-name.js +79 -0
- package/dist/iso/view-transition-types.d.ts +19 -0
- package/dist/iso/view-transition-types.js +36 -0
- package/dist/server/render.js +95 -52
- package/dist/vite/client-entry.js +16 -9
- package/package.json +2 -2
|
@@ -1,18 +1,419 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
18
|
+
legacySubs.add(sub);
|
|
15
19
|
return () => {
|
|
16
|
-
|
|
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
|
+
}
|
package/dist/iso/internal.d.ts
CHANGED
|
@@ -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 {
|
|
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';
|
package/dist/iso/internal.js
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|