@abide/abide 0.38.1 → 0.39.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/AGENTS.md +3 -3
- package/CHANGELOG.md +16 -0
- package/package.json +9 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
- package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
- package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
- package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
- package/src/lib/shared/createRemoteFunction.ts +20 -11
- package/src/lib/shared/escapeHtml.ts +15 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
- package/src/lib/shared/streamResponse.ts +8 -1
- package/src/lib/shared/types/RemoteCallable.ts +12 -3
- package/src/lib/shared/types/RpcOptions.ts +22 -0
- package/src/lib/shared/types/SourceMap.ts +14 -0
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
- package/src/lib/ui/compile/compileModule.ts +5 -2
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +11 -3
- package/src/lib/ui/compile/composeProps.ts +53 -0
- package/src/lib/ui/compile/desugarSignals.ts +45 -17
- package/src/lib/ui/compile/generateBuild.ts +46 -18
- package/src/lib/ui/compile/generateSSR.ts +196 -52
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +44 -1
- package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
- package/src/lib/ui/compile/staticAttr.ts +1 -1
- package/src/lib/ui/compile/staticTextPart.ts +1 -1
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
- package/src/lib/ui/dom/mergeProps.ts +32 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/installHotBridge.ts +10 -0
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +39 -37
- package/src/lib/ui/renderToStream.ts +69 -61
- package/src/lib/ui/resumeSeedScript.ts +17 -0
- package/src/lib/ui/router.ts +81 -51
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
package/src/lib/ui/router.ts
CHANGED
|
@@ -224,8 +224,10 @@ export function router(
|
|
|
224
224
|
return
|
|
225
225
|
}
|
|
226
226
|
const target = event.target as Element
|
|
227
|
+
/* `closest?.` is undefined when the target is a non-Element (text node, document)
|
|
228
|
+
that has no `closest`; `== null` catches both that and a genuine no-match null. */
|
|
227
229
|
const link = target.closest?.('a[href]') as HTMLAnchorElement | null
|
|
228
|
-
if (link
|
|
230
|
+
if (link == null) {
|
|
229
231
|
return
|
|
230
232
|
}
|
|
231
233
|
/* Defer to the browser for links it should own: a new-tab target, a
|
|
@@ -341,76 +343,104 @@ export function router(
|
|
|
341
343
|
resolvePage(key),
|
|
342
344
|
Promise.all(chainKeys.map((layoutKey) => resolveLayout(layoutKey))),
|
|
343
345
|
verdict,
|
|
344
|
-
])
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
346
|
+
])
|
|
347
|
+
.then(([pageView, resolvedLayouts, decision]) => {
|
|
348
|
+
if (token !== sequence || disposed) {
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
/* handle() redirected: go where it pointed, replacing the blocked
|
|
349
352
|
URL so back doesn't trap on it. The router re-probes the target. */
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
if (decision.kind === 'redirect') {
|
|
354
|
+
navigate(decision.path, true)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
/* handle() blocked it / redirected off-origin / the probe failed:
|
|
355
358
|
let the browser load the server's real response. */
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
+
if (decision.kind === 'reload') {
|
|
360
|
+
if (typeof location !== 'undefined') {
|
|
361
|
+
location.href = decision.url
|
|
362
|
+
}
|
|
363
|
+
return
|
|
359
364
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
(
|
|
364
|
-
)
|
|
365
|
-
/* The shared prefix of layouts (same route key at the same depth) stays
|
|
365
|
+
const layoutViews = resolvedLayouts.filter(
|
|
366
|
+
(view): view is Route => view !== undefined,
|
|
367
|
+
)
|
|
368
|
+
/* The shared prefix of layouts (same route key at the same depth) stays
|
|
366
369
|
mounted; the first divergence and everything inward is rebuilt. */
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
370
|
+
let divergence = 0
|
|
371
|
+
while (
|
|
372
|
+
divergence < mountedLayouts.length &&
|
|
373
|
+
divergence < chainKeys.length &&
|
|
374
|
+
mountedLayouts[divergence]?.key === chainKeys[divergence]
|
|
375
|
+
) {
|
|
376
|
+
divergence += 1
|
|
377
|
+
}
|
|
378
|
+
const hydrating = first && pageView?.hydratable === true
|
|
379
|
+
first = false
|
|
380
|
+
/* The DOM mutation a navigation makes: tear the divergent chain down
|
|
378
381
|
(clearing its content from its boundary) and rebuild into the same
|
|
379
382
|
boundary (hydration adopts in place). */
|
|
380
|
-
|
|
381
|
-
|
|
383
|
+
const swap = (): void => {
|
|
384
|
+
/* `startViewTransition` runs this callback in a later frame, so a newer
|
|
385
|
+
navigation may have superseded this one since the token guard above —
|
|
386
|
+
re-check before mutating, or a stale swap clobbers the newer page. */
|
|
387
|
+
if (token !== sequence || disposed) {
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
/* Tear the outgoing page + divergent layouts down BEFORE publishing the
|
|
382
391
|
new snapshot. Publishing first would re-run the doomed leaf page's
|
|
383
392
|
computeds against the new route's params (a missing `[id]` reads back
|
|
384
393
|
`undefined`, e.g. `Number(page.params.id)` → NaN → a bogus request)
|
|
385
394
|
while it's still mounted. Disposing first kills that scope; surviving
|
|
386
395
|
prefix layouts then update in place on publish. */
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
396
|
+
disposeFrom(divergence)
|
|
397
|
+
const url = resolveUrl(path)
|
|
398
|
+
clientPage.value = { route: chainRoute, params, url, navigating: false }
|
|
399
|
+
buildFrom(
|
|
400
|
+
divergence,
|
|
401
|
+
chainKeys,
|
|
402
|
+
layoutViews,
|
|
403
|
+
pageView,
|
|
404
|
+
key,
|
|
405
|
+
params,
|
|
406
|
+
hydrating,
|
|
407
|
+
)
|
|
408
|
+
/* Reapply the destination entry's scroll once its DOM exists — a
|
|
392
409
|
back/forward restores its offset, a fresh nav scrolls to the `#hash`
|
|
393
410
|
anchor (now built) or the top. Runs on the initial paint too: with
|
|
394
411
|
`scrollRestoration='manual'` the browser does NOT restore a reload's
|
|
395
412
|
offset, so first paint recovers it from the persisted `history.state`
|
|
396
413
|
(a fresh load with no persisted offset falls through to hash/top). */
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
414
|
+
historyEntries.restore(url.hash)
|
|
415
|
+
}
|
|
416
|
+
/* Wrap the swap in a View Transition where the browser supports it, so
|
|
400
417
|
the page change cross-fades (and shared `view-transition-name` elements
|
|
401
418
|
morph) — the synchronous swap is exactly the mutation the API snapshots
|
|
402
419
|
around. Skipped while hydrating: the first paint adopts SSR DOM in place,
|
|
403
420
|
not animate. CSS owns opting out (e.g. prefers-reduced-motion). */
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
421
|
+
if (
|
|
422
|
+
!hydrating &&
|
|
423
|
+
typeof document !== 'undefined' &&
|
|
424
|
+
'startViewTransition' in document
|
|
425
|
+
) {
|
|
426
|
+
document.startViewTransition(swap)
|
|
427
|
+
} else {
|
|
428
|
+
swap()
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
.catch(() => {
|
|
432
|
+
/* A page/layout chunk import (or the probe) rejected — offline, a hashed
|
|
433
|
+
chunk filename rotated by a deploy, or a transient asset 5xx. Without
|
|
434
|
+
this the navigating:true latched above never clears (a bound spinner
|
|
435
|
+
spins forever) and the rejection surfaces as an unhandledrejection.
|
|
436
|
+
Fall back to a full browser load so the server serves the target. */
|
|
437
|
+
if (token !== sequence || disposed) {
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (typeof location !== 'undefined') {
|
|
441
|
+
location.href = resolveUrl(path).href
|
|
442
|
+
}
|
|
443
|
+
})
|
|
414
444
|
})
|
|
415
445
|
})
|
|
416
446
|
|
|
@@ -49,6 +49,11 @@ export function createEffectNode(fn: () => EffectResult): () => void {
|
|
|
49
49
|
const dispose = () => {
|
|
50
50
|
runCleanup()
|
|
51
51
|
unlinkDeps(node)
|
|
52
|
+
/* Clearing compute makes runNode a no-op: an effect disposed mid-flush (by an
|
|
53
|
+
earlier effect in the same batch) is still in flushEffects' snapshot array
|
|
54
|
+
after pendingEffects.delete, so it would otherwise re-run its body and
|
|
55
|
+
re-link into the graph — a disposed effect resurrected. */
|
|
56
|
+
node.compute = undefined
|
|
52
57
|
REACTIVE_CONTEXT.pendingEffects.delete(node)
|
|
53
58
|
}
|
|
54
59
|
if (OWNER.current !== undefined) {
|
|
@@ -25,7 +25,14 @@ export function localStoragePersistence(): PersistenceStore | undefined {
|
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
save: (key, snapshot) => {
|
|
28
|
-
|
|
28
|
+
/* Swallow a failed write (QuotaExceededError, storage disabled mid-session) —
|
|
29
|
+
it fires from a debounced flush / pagehide handler with no caller to catch it,
|
|
30
|
+
and a dropped persist must not crash the app. */
|
|
31
|
+
try {
|
|
32
|
+
localStorage.setItem(key, JSON.stringify(snapshot))
|
|
33
|
+
} catch {
|
|
34
|
+
// best-effort persistence
|
|
35
|
+
}
|
|
29
36
|
},
|
|
30
37
|
remove: (key) => {
|
|
31
38
|
localStorage.removeItem(key)
|
|
@@ -15,11 +15,16 @@ export function toTeardown(result: EffectResult): Teardown | undefined {
|
|
|
15
15
|
}
|
|
16
16
|
if (result instanceof Promise) {
|
|
17
17
|
return () => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
/* Swallow a rejection: an async body that rejected (e.g. an aborted RPC) must
|
|
19
|
+
not surface as an unhandled rejection when the teardown runs at dispose. */
|
|
20
|
+
result.then(
|
|
21
|
+
(teardown) => {
|
|
22
|
+
if (typeof teardown === 'function') {
|
|
23
|
+
teardown()
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
() => undefined,
|
|
27
|
+
)
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
return undefined
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* The per-request SSR render context, threaded through a layout chain and every
|
|
2
|
+
child component a page inlines. `next` is the block-id counter: each `await`/`try`
|
|
3
|
+
block draws from it in depth-first document order — the SAME order the client
|
|
4
|
+
allocates ids during its synchronous hydration walk, so the streamed fragments and
|
|
5
|
+
the `RESUME` manifest line up. Request-local (not a module global) because SSR
|
|
6
|
+
render is async — a blocking `await` yields, and a shared global counter would
|
|
7
|
+
interleave across concurrent requests. */
|
|
8
|
+
export type RenderContext = { next: number }
|
|
@@ -1,22 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import type { ResumeEntry } from '../RESUME.ts'
|
|
2
|
+
|
|
3
|
+
/* One STREAMING await block captured during SSR (no `then` on the `await` tag): its
|
|
4
|
+
boundary id, the promise to await, and the async string-renderers for the resolved
|
|
5
|
+
value / error. `renderToStream` flushes each resolved fragment out of order. The
|
|
6
|
+
renderers are async so a nested `await` block inside the branch composes. `catch`
|
|
7
|
+
is absent when the block has no catch branch — a rejection then surfaces to the
|
|
8
|
+
stream/error path instead of rendering an empty branch. (Blocking awaits — a `then`
|
|
9
|
+
on the tag — never land here: they render inline during the async render pass and
|
|
10
|
+
seed `SsrRender.resume`.) */
|
|
7
11
|
export type SsrAwait = {
|
|
8
12
|
id: number
|
|
9
|
-
blocking?: boolean
|
|
10
13
|
promise: () => unknown
|
|
11
|
-
then: (value: unknown) => string
|
|
12
|
-
catch?: (error: unknown) => string
|
|
14
|
+
then: (value: unknown) => Promise<string>
|
|
15
|
+
catch?: (error: unknown) => Promise<string>
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/* The result of a component's server `render()`: the pending-shell HTML, the
|
|
16
|
-
serializable document snapshot for client resume,
|
|
17
|
-
|
|
19
|
+
serializable document snapshot for client resume, the STREAMING await blocks to
|
|
20
|
+
flush out of order, and `resume` — the inline-rendered BLOCKING await values keyed
|
|
21
|
+
by boundary id, seeded into the manifest so hydration adopts them without a refetch. */
|
|
18
22
|
export type SsrRender = {
|
|
19
23
|
html: string
|
|
20
24
|
state: unknown
|
|
21
25
|
awaits: SsrAwait[]
|
|
26
|
+
resume: Record<number, ResumeEntry>
|
|
22
27
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RenderContext } from './RenderContext.ts'
|
|
1
2
|
import type { SsrRender } from './SsrRender.ts'
|
|
2
3
|
import type { UiProps } from './UiProps.ts'
|
|
3
4
|
|
|
@@ -7,7 +8,12 @@ for SSR and the hydration hooks. This is the shape `compileModule` emits and the
|
|
|
7
8
|
page/route registries carry — abide-ui's compiled-component shape.
|
|
8
9
|
*/
|
|
9
10
|
export type UiComponent = ((host: Element, props?: UiProps) => () => void) & {
|
|
10
|
-
|
|
11
|
+
/* `ctx` is the request-local block-id counter, threaded so a child's ids share the
|
|
12
|
+
page's depth-first numbering; omitted at the top level (a fresh counter defaults
|
|
13
|
+
in). Returns a Promise when the component has an inline `await` (a blocking `{#await
|
|
14
|
+
… then}` block or a child render); otherwise renders synchronously. Callers `await`
|
|
15
|
+
it either way (awaiting a sync value just returns it). */
|
|
16
|
+
render: (props?: UiProps, ctx?: RenderContext) => SsrRender | Promise<SsrRender>
|
|
11
17
|
hydrate?: (host: Element, props?: UiProps) => () => void
|
|
12
18
|
/* The bare client build (`(host, props) => void`) — appends the component's nodes
|
|
13
19
|
to `host`. A nested child mounts it into a marker range (`mountRange`/`mountChild`)
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
HTML-escapes a compile-time-constant string — a static attribute value or static
|
|
3
|
-
text — the same five characters the runtime `$esc` handles. Shared by the SSR
|
|
4
|
-
generator and the static-clone skeleton generator so server markup and the client
|
|
5
|
-
clone template can't diverge on escaping. Static text reaches here already
|
|
6
|
-
entity-decoded (see parseTemplate), so escaping round-trips it through the HTML
|
|
7
|
-
parser to the same plain text the client would build directly.
|
|
8
|
-
*/
|
|
9
|
-
export function escapeHtml(value: string): string {
|
|
10
|
-
return value.replace(
|
|
11
|
-
/[&<>"']/g,
|
|
12
|
-
(char) =>
|
|
13
|
-
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char] ?? char,
|
|
14
|
-
)
|
|
15
|
-
}
|
|
File without changes
|