@webjsdev/core 0.8.0 → 0.9.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/README.md +5 -0
- package/dist/webjs-core-browser.js +6 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -19
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +14 -9
- package/index.d.ts +22 -10
- package/index.js +17 -9
- package/package.json +15 -2
- package/src/action-effects-client.d.ts +5 -0
- package/src/action-effects-client.js +84 -0
- package/src/action-seed-client.js +5 -5
- package/src/asset-url.js +12 -13
- package/src/async-directive.d.ts +18 -0
- package/src/async-directive.js +88 -0
- package/src/attribute-reader.js +197 -23
- package/src/binding-prefixes.js +0 -12
- package/src/component/lifecycle.js +760 -209
- package/src/component/reactive.js +2 -10
- package/src/component/server-element.js +23 -1
- package/src/component.d.ts +151 -41
- package/src/context.d.ts +94 -18
- package/src/context.js +341 -217
- package/src/converters.d.ts +7 -0
- package/src/converters.js +38 -0
- package/src/css.d.ts +32 -6
- package/src/css.js +178 -14
- package/src/directive.d.ts +58 -0
- package/src/directive.js +119 -0
- package/src/directives.d.ts +38 -1
- package/src/directives.js +158 -1
- package/src/form-action.d.ts +13 -0
- package/src/form-action.js +139 -4
- package/src/hot.js +23 -0
- package/src/html.d.ts +4 -0
- package/src/html.js +35 -3
- package/src/is-server.d.ts +2 -0
- package/src/is-server.js +18 -0
- package/src/jsx-guard.js +130 -0
- package/src/metadata.d.ts +22 -3
- package/src/nav.d.ts +4 -1
- package/src/nav.js +44 -11
- package/src/optimistic.js +2 -2
- package/src/refresh-header.js +52 -0
- package/src/registry.js +10 -1
- package/src/render-client/adopt-slots.js +8 -3
- package/src/render-client/parts.js +745 -31
- package/src/render-client/reconciler.js +56 -11
- package/src/render-client/template-compiler.js +139 -44
- package/src/render-client.d.ts +5 -1
- package/src/render-server/attrs.js +2 -2
- package/src/render-server/compile.js +34 -0
- package/src/render-server/errors.js +37 -0
- package/src/render-server/expand.js +61 -8
- package/src/render-server/served-attrs.js +1 -2
- package/src/render-server/walk.js +135 -3
- package/src/render-server.d.ts +3 -0
- package/src/render-server.js +2 -0
- package/src/repeat.d.ts +7 -2
- package/src/repeat.js +20 -6
- package/src/router-client/dom-differ.js +17 -0
- package/src/router-client/fetch-apply.js +86 -5
- package/src/router-client/navigator.js +45 -9
- package/src/router-client/prefetch.js +3 -4
- package/src/router-client/root-attrs.js +43 -0
- package/src/router-client/stream.js +7 -0
- package/src/router-client/swap.js +4 -1
- package/src/routes.d.ts +75 -18
- package/src/serialize.d.ts +2 -0
- package/src/serialize.js +35 -4
- package/src/signal.d.ts +7 -0
- package/src/signal.js +26 -6
- package/src/slot/polyfills.js +3 -3
- package/src/slot.js +4 -8
- package/src/static-html.d.ts +22 -0
- package/src/static-html.js +144 -0
- package/src/task.d.ts +63 -20
- package/src/task.js +326 -293
- package/src/webjs-config.d.ts +1 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { stylesToString,
|
|
1
|
+
import { stylesToString, finalizeStyles, lightDomStylesMessage } from '../css.js';
|
|
2
2
|
import { unsafeHTML } from '../directives.js';
|
|
3
3
|
import { escapeAttr } from '../escape.js';
|
|
4
|
-
import { defaultSSRErrorTemplate, browserMemberHint } from './errors.js';
|
|
4
|
+
import { defaultSSRErrorTemplate, browserMemberHint, isProd } from './errors.js';
|
|
5
5
|
import {
|
|
6
6
|
applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
|
|
7
7
|
withHostMarker,
|
|
@@ -45,6 +45,7 @@ const NON_BLANK = /\S/;
|
|
|
45
45
|
export function beginComponent(w, tag, Cls, opening, attrs) {
|
|
46
46
|
if (w.ctx && w.ctx.usedComponents) w.ctx.usedComponents.add(tag);
|
|
47
47
|
const isShadow = Cls.shadow === true;
|
|
48
|
+
if (!isShadow) reportLightDomStyles(w, tag, Cls);
|
|
48
49
|
const frame = {
|
|
49
50
|
kind: 'component', tag, Cls, isShadow, instance: null, opening, tpl: undefined,
|
|
50
51
|
pending: /** @type {Promise<unknown> | null} */ (null), presentAttrNames: /** @type {Set<string> | null} */ (null),
|
|
@@ -142,7 +143,7 @@ export async function finishComponent(w, frame, children) {
|
|
|
142
143
|
// Native <slot> stays as-is in the DSD template: the browser projects
|
|
143
144
|
// the host's light children natively. The authored children stay in
|
|
144
145
|
// place after the template, and the source end tag is kept.
|
|
145
|
-
return `${frame.opening}
|
|
146
|
+
return `${frame.opening}${dsdOpenTag(Cls)}${styleString(Cls)}${inner}</template>${children}${frame.closeText}`;
|
|
146
147
|
}
|
|
147
148
|
if (!NON_BLANK.test(inner)) {
|
|
148
149
|
// An empty rendered template (a decorator component with no render(),
|
|
@@ -169,10 +170,12 @@ export async function finishComponent(w, frame, children) {
|
|
|
169
170
|
async function renderOwnTemplate(w, frame, children) {
|
|
170
171
|
const owner = w.owner;
|
|
171
172
|
w.owner = frame.isShadow ? null : { tag: frame.tag, partition: partitionChildren(children, frame.segments), consumed: null };
|
|
173
|
+
w.componentDepth = (w.componentDepth || 0) + 1;
|
|
172
174
|
try {
|
|
173
175
|
return await w.capture(() => w.value(frame.tpl));
|
|
174
176
|
} finally {
|
|
175
177
|
w.owner = owner;
|
|
178
|
+
w.componentDepth -= 1;
|
|
176
179
|
}
|
|
177
180
|
}
|
|
178
181
|
|
|
@@ -204,12 +207,54 @@ function partitionChildren(children, segments) {
|
|
|
204
207
|
return groups;
|
|
205
208
|
}
|
|
206
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The Declarative Shadow DOM opening tag for a shadow component, carrying
|
|
212
|
+
* Lit's `static shadowRootOptions` as the template attributes the HTML parser
|
|
213
|
+
* maps onto `attachShadow()`: the mode, `delegatesFocus`, `clonable` and
|
|
214
|
+
* `serializable`. The common case (no options) is the constant open tag.
|
|
215
|
+
* @param {any} Cls
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
export function dsdOpenTag(Cls) {
|
|
219
|
+
const o = Cls.shadowRootOptions;
|
|
220
|
+
if (!o) return OPEN_DSD;
|
|
221
|
+
const mode = o.mode === 'closed' ? 'closed' : 'open';
|
|
222
|
+
if (mode === 'open' && !o.delegatesFocus && !o.clonable && !o.serializable) return OPEN_DSD;
|
|
223
|
+
return `<template shadowrootmode="${mode}"` +
|
|
224
|
+
(o.delegatesFocus ? ' shadowrootdelegatesfocus' : '') +
|
|
225
|
+
(o.clonable ? ' shadowrootclonable' : '') +
|
|
226
|
+
(o.serializable ? ' shadowrootserializable' : '') + '>';
|
|
227
|
+
}
|
|
228
|
+
const OPEN_DSD = '<template shadowrootmode="open">';
|
|
229
|
+
|
|
207
230
|
function styleString(Cls) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return stylesToString(
|
|
231
|
+
// `elementStyles` is the class's finalized list (Lit's `finalizeStyles`);
|
|
232
|
+
// a class not built on WebComponent has none, so flatten its styles here.
|
|
233
|
+
return stylesToString(Cls.elementStyles ?? finalizeStyles(Cls.styles));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Light-DOM classes already reported for `static styles`. @type {WeakSet<Function>} */
|
|
237
|
+
const REPORTED_LIGHT_STYLES = new WeakSet();
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A light-DOM component's `static styles` never apply (there is no shadow
|
|
241
|
+
* root to adopt them into). Report it once per class in dev, on the server,
|
|
242
|
+
* so the cause shows up in the terminal as well as the browser console.
|
|
243
|
+
* @param {import('./walk.js').Walker} w
|
|
244
|
+
* @param {string} tag
|
|
245
|
+
* @param {any} Cls
|
|
246
|
+
*/
|
|
247
|
+
function reportLightDomStyles(w, tag, Cls) {
|
|
248
|
+
if (!Cls.styles || REPORTED_LIGHT_STYLES.has(Cls)) return;
|
|
249
|
+
const dev = w.dev === undefined ? !isProd() : !!w.dev;
|
|
250
|
+
if (!dev) return;
|
|
251
|
+
REPORTED_LIGHT_STYLES.add(Cls);
|
|
252
|
+
console.error(lightDomStylesMessage(tag));
|
|
211
253
|
}
|
|
212
254
|
|
|
255
|
+
/** Key of the hook the server installs to hear about isolated component failures. */
|
|
256
|
+
const COMPONENT_ERROR_HOOK = Symbol.for('webjs.ssr.componentError');
|
|
257
|
+
|
|
213
258
|
/**
|
|
214
259
|
* Per-component error isolation (#469). The element renders a
|
|
215
260
|
* component-scoped error state in its place (renderError() customises it;
|
|
@@ -230,20 +275,28 @@ async function errorState(w, frame) {
|
|
|
230
275
|
} else {
|
|
231
276
|
console.error(`[webjs] SSR failed for <${tag}>:`, e);
|
|
232
277
|
}
|
|
278
|
+
// The page still renders (the failure is isolated to this element), so the
|
|
279
|
+
// server hears about it only through this hook: @webjsdev/server installs it
|
|
280
|
+
// and routes it to the current request's dev status.
|
|
281
|
+
const report = /** @type {any} */ (globalThis)[COMPONENT_ERROR_HOOK];
|
|
282
|
+
if (typeof report === 'function') {
|
|
283
|
+
try { report(tag, e); } catch { /* a reporting failure must not affect the render */ }
|
|
284
|
+
}
|
|
233
285
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
234
286
|
let errorInner = '';
|
|
235
287
|
try {
|
|
236
288
|
let errTpl;
|
|
237
289
|
if (instance && typeof instance.renderError === 'function') errTpl = instance.renderError(err);
|
|
238
290
|
if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, w.dev);
|
|
239
|
-
|
|
291
|
+
w.componentDepth = (w.componentDepth || 0) + 1;
|
|
292
|
+
try { errorInner = await w.capture(() => w.value(errTpl)); } finally { w.componentDepth -= 1; }
|
|
240
293
|
} catch (renderErrorThrew) {
|
|
241
294
|
console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
|
|
242
295
|
errorInner = '';
|
|
243
296
|
}
|
|
244
297
|
let opening = frame.opening;
|
|
245
298
|
if (!isShadow) opening = withHostMarker(opening);
|
|
246
|
-
if (isShadow) return `${opening}
|
|
299
|
+
if (isShadow) return `${opening}${dsdOpenTag(Cls)}${styleString(Cls)}${errorInner}</template>`;
|
|
247
300
|
return `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
|
|
248
301
|
}
|
|
249
302
|
|
|
@@ -16,8 +16,7 @@ import { appendReflectedAttrs } from './attrs.js';
|
|
|
16
16
|
* - one the element REMOVED is dropped from the tag.
|
|
17
17
|
* An attribute the element left alone keeps its source text BYTE FOR BYTE
|
|
18
18
|
* (its quoting, its entity spelling, its position), so a page whose
|
|
19
|
-
* components touch nothing renders exactly as before
|
|
20
|
-
* on-versus-off differential relies on.
|
|
19
|
+
* components touch nothing renders exactly as before.
|
|
21
20
|
*
|
|
22
21
|
* What stays as written, and why each is safe:
|
|
23
22
|
* - `data-webjs-prop-*`: a parent's `.prop=${value}` binding, consumed into
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
isSubmitterReflectedProp, FORM_ACTION_FIELD,
|
|
13
13
|
} from '../form-action.js';
|
|
14
14
|
import { isRepeat } from '../repeat.js';
|
|
15
|
+
import { isDirectiveResult, PartType } from '../directive.js';
|
|
16
|
+
import { isSignal } from '../signal.js';
|
|
15
17
|
import { isSuspense } from '../suspense.js';
|
|
16
18
|
import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from '../directives.js';
|
|
17
19
|
import { stringify } from '../serialize.js';
|
|
@@ -83,6 +85,8 @@ export class Walker {
|
|
|
83
85
|
this.defer = 0;
|
|
84
86
|
/** @type {any} */
|
|
85
87
|
this.tagState = null;
|
|
88
|
+
/** How many component templates (render / renderError) are being walked. */
|
|
89
|
+
this.componentDepth = 0;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
// -------------------------------------------------------------------------
|
|
@@ -165,6 +169,10 @@ export class Walker {
|
|
|
165
169
|
* @param {unknown} value
|
|
166
170
|
*/
|
|
167
171
|
async value(value) {
|
|
172
|
+
if (isDirectiveResult(value)) value = resolveDirective(value, CHILD_PART);
|
|
173
|
+
// A bare `${signal}` renders its current value (the client binds the hole
|
|
174
|
+
// to the signal so it updates in place, as `watch()` does).
|
|
175
|
+
if (isSignal(value)) value = /** @type any */ (value).get();
|
|
168
176
|
if (value == null || value === nothing || value === noChange) return;
|
|
169
177
|
if (typeof (/** @type any */ (value).then) === 'function') {
|
|
170
178
|
return this.value(await value);
|
|
@@ -175,6 +183,14 @@ export class Walker {
|
|
|
175
183
|
for (const v of value) await this.value(v);
|
|
176
184
|
return;
|
|
177
185
|
}
|
|
186
|
+
// Any other iterable renders like an array, as in lit: a generator from
|
|
187
|
+
// `map()` / `join()` / `range()`, a Set, a Map's values. A string is
|
|
188
|
+
// iterable too and was handled above, and every directive marker is a
|
|
189
|
+
// plain object with no iterator, so none of them reaches this branch.
|
|
190
|
+
if (typeof value === 'object' && typeof (/** @type any */ (value))[Symbol.iterator] === 'function') {
|
|
191
|
+
for (const v of /** @type {Iterable<unknown>} */ (value)) await this.value(v);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
178
194
|
// An unsafeHTML value is raw markup. With expansion on, the components it
|
|
179
195
|
// carries render too (the output rescan used to find them), through the
|
|
180
196
|
// same tokenizer the template went through, so a tag written inside one
|
|
@@ -244,6 +260,12 @@ export class Walker {
|
|
|
244
260
|
|
|
245
261
|
/** @param {import('../html.js').TemplateResult} tr */
|
|
246
262
|
async template(tr) {
|
|
263
|
+
// Only a tagged template's strings carry `raw`, so a result that arrived as
|
|
264
|
+
// data (JSON with the `_$webjs` brand) is refused rather than rendered as
|
|
265
|
+
// markup, as the client renderer and lit's `trustFromTemplateString` do.
|
|
266
|
+
if (!Array.isArray(tr.strings) || !Object.prototype.hasOwnProperty.call(tr.strings, 'raw')) {
|
|
267
|
+
throw new Error("[webjs] refusing to render a template result whose strings are not a tagged template's (a TemplateResult built from data, not by html``).");
|
|
268
|
+
}
|
|
247
269
|
await this.ops(compile(tr.strings), tr.values);
|
|
248
270
|
this.flush();
|
|
249
271
|
}
|
|
@@ -317,6 +339,9 @@ export class Walker {
|
|
|
317
339
|
if (piece && typeof piece.then === 'function') piece = await piece;
|
|
318
340
|
if (isLive(piece)) piece = piece.value;
|
|
319
341
|
}
|
|
342
|
+
piece = await attrValueOf(piece);
|
|
343
|
+
if (isDirectiveResult(piece)) piece = resolveDirective(piece, { type: PartType.ATTRIBUTE, name: op.name, tagName: op.tag?.toUpperCase() });
|
|
344
|
+
if (isSignal(piece) || isWatch(piece)) piece = signalValue(piece);
|
|
320
345
|
if (isAttributeMap(piece)) piece = attributeMapText(piece, op.name);
|
|
321
346
|
assertNotFunctionActionAttr(piece, op.name, op.tag);
|
|
322
347
|
if (piece === nothing) omitted = true;
|
|
@@ -336,6 +361,9 @@ export class Walker {
|
|
|
336
361
|
}
|
|
337
362
|
// A hole inside a quoted/unquoted value, the `action="${fn}"` and
|
|
338
363
|
// mixed `action="/x/${fn}"` shapes (#1154).
|
|
364
|
+
val = await attrValueOf(val);
|
|
365
|
+
if (isDirectiveResult(val)) val = resolveDirective(val, { type: PartType.ATTRIBUTE, name: op.name, tagName: op.tag?.toUpperCase() });
|
|
366
|
+
if (isSignal(val) || isWatch(val)) val = signalValue(val);
|
|
339
367
|
assertNotFunctionActionAttr(val, op.name, op.tag);
|
|
340
368
|
this.buf += escapeAttr(String(val ?? ''));
|
|
341
369
|
break;
|
|
@@ -351,6 +379,8 @@ export class Walker {
|
|
|
351
379
|
// -------------------------------------------------------------------------
|
|
352
380
|
// Start tags
|
|
353
381
|
// -------------------------------------------------------------------------
|
|
382
|
+
// (attrValueOf, the attribute-position unwrap of the value-carrying
|
|
383
|
+
// built-in directives, is a module function below.)
|
|
354
384
|
|
|
355
385
|
/** @param {string} tag */
|
|
356
386
|
open(tag) {
|
|
@@ -400,6 +430,18 @@ export class Walker {
|
|
|
400
430
|
* @param {unknown} val
|
|
401
431
|
*/
|
|
402
432
|
async attr(op, val) {
|
|
433
|
+
if (op.kind !== 'event') val = await attrValueOf(val);
|
|
434
|
+
// A custom directive renders its `render()` result at SSR (lit's server
|
|
435
|
+
// rule: `update()` needs a DOM). An event part is client-only, so its
|
|
436
|
+
// directive never runs here.
|
|
437
|
+
if (isDirectiveResult(val) && op.kind !== 'event') {
|
|
438
|
+
const type = op.kind === 'prop' ? PartType.PROPERTY : op.kind === 'bool' ? PartType.BOOLEAN_ATTRIBUTE : PartType.ATTRIBUTE;
|
|
439
|
+
val = resolveDirective(val, { type, name: op.name, tagName: op.tag?.toUpperCase() });
|
|
440
|
+
}
|
|
441
|
+
// A bare signal in an attribute or boolean hole renders its current value;
|
|
442
|
+
// a `.prop` hole hands the signal object itself to the property, as lit's
|
|
443
|
+
// signals package does.
|
|
444
|
+
if ((isSignal(val) || isWatch(val)) && (op.kind === 'plain' || op.kind === 'bool')) val = signalValue(val);
|
|
403
445
|
if (isAttributeMap(val)) {
|
|
404
446
|
if (op.kind !== 'plain') throw new Error('classMap/styleMap require an attribute binding.');
|
|
405
447
|
val = attributeMapText(val, op.name);
|
|
@@ -410,7 +452,10 @@ export class Walker {
|
|
|
410
452
|
const name = /** @type string */ (op.name);
|
|
411
453
|
const tag = /** @type string */ (op.tag);
|
|
412
454
|
if (op.kind === 'event') {
|
|
413
|
-
// An event listener is client-only behaviour, dropped at SSR.
|
|
455
|
+
// An event listener is client-only behaviour, dropped at SSR. Outside
|
|
456
|
+
// any component template it can never run either: a page or layout
|
|
457
|
+
// never hydrates. Dev makes that a render error naming the fix.
|
|
458
|
+
if (this.dev && this.componentDepth === 0) throw new Error(pageBindingError(true, name, tag));
|
|
414
459
|
return;
|
|
415
460
|
}
|
|
416
461
|
if (op.kind === 'prop') {
|
|
@@ -424,6 +469,9 @@ export class Walker {
|
|
|
424
469
|
return;
|
|
425
470
|
}
|
|
426
471
|
if (!tag.includes('-')) {
|
|
472
|
+
if (this.dev && this.componentDepth === 0 && !isReflectedFormProp(name, tag)) {
|
|
473
|
+
throw new Error(pageBindingError(false, name, tag));
|
|
474
|
+
}
|
|
427
475
|
// A native element's `.prop` is dropped at SSR. It still refuses a
|
|
428
476
|
// function where the property is a REFLECTED IDL attribute (`.action`
|
|
429
477
|
// on a form, `.formAction` on a button or input), and records the
|
|
@@ -464,7 +512,7 @@ export class Walker {
|
|
|
464
512
|
// page's own url; the identity is remembered for the `>`. Trailing
|
|
465
513
|
// whitespace goes with the attribute, since every injected attribute
|
|
466
514
|
// carries its own leading space.
|
|
467
|
-
if (ts) ts.actionId = assertIdentifiableAction(await resolveFormActionId(val), tag);
|
|
515
|
+
if (ts) ts.actionId = assertIdentifiableAction(await resolveFormActionId(val), tag, val);
|
|
468
516
|
this.buf = this.buf.replace(/\s+$/, '');
|
|
469
517
|
} else {
|
|
470
518
|
// #1207, the submitter binding. The identity replaces the
|
|
@@ -475,7 +523,7 @@ export class Walker {
|
|
|
475
523
|
if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', tag, false);
|
|
476
524
|
if (attrs.has('value')) assertSubmitterHasNoValue(tag);
|
|
477
525
|
if (attrs.has('form')) assertSubmitterHasNoFormAttribute(tag);
|
|
478
|
-
const subId = assertIdentifiableAction(await resolveFormActionId(val), tag);
|
|
526
|
+
const subId = assertIdentifiableAction(await resolveFormActionId(val), tag, val);
|
|
479
527
|
if (ts) ts.submitterTag = tag;
|
|
480
528
|
this.buf += `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
|
|
481
529
|
}
|
|
@@ -726,6 +774,37 @@ export class Walker {
|
|
|
726
774
|
|
|
727
775
|
const EMPTY = Object.freeze([]);
|
|
728
776
|
|
|
777
|
+
/**
|
|
778
|
+
* The current value of a bare signal or a `watch(signal)` marker in an
|
|
779
|
+
* attribute-shaped hole (SSR reads it once; the client binds the hole).
|
|
780
|
+
* @param {any} v
|
|
781
|
+
*/
|
|
782
|
+
function signalValue(v) {
|
|
783
|
+
return isWatch(v) ? v.signal.get() : v.get();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** The part info every child-position directive is constructed with. */
|
|
787
|
+
const CHILD_PART = Object.freeze({ type: PartType.CHILD });
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Resolve a custom directive result (`directive(C)(...values)`) to the value
|
|
791
|
+
* it renders, lit's server rule: construct the class with the part info and
|
|
792
|
+
* call `render(...values)` (never `update()`, which needs a DOM). A directive
|
|
793
|
+
* that returns another directive resolves in turn. A fresh instance per
|
|
794
|
+
* render is right here, since an SSR render is one shot.
|
|
795
|
+
* @param {any} value
|
|
796
|
+
* @param {{ type: number, name?: string, tagName?: string }} partInfo
|
|
797
|
+
* @returns {unknown}
|
|
798
|
+
*/
|
|
799
|
+
function resolveDirective(value, partInfo) {
|
|
800
|
+
for (let depth = 0; isDirectiveResult(value); depth++) {
|
|
801
|
+
if (depth > 100) throw new Error('[webjs] a custom directive kept returning directives (more than 100 levels deep).');
|
|
802
|
+
const instance = new value.ctor(partInfo);
|
|
803
|
+
value = instance.render(...value.values);
|
|
804
|
+
}
|
|
805
|
+
return value;
|
|
806
|
+
}
|
|
807
|
+
|
|
729
808
|
/**
|
|
730
809
|
* #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped at
|
|
731
810
|
* SSR but applied for real in the browser, where all three are reflected IDL
|
|
@@ -796,3 +875,56 @@ export async function streamValue(value, ctx, controller) {
|
|
|
796
875
|
await w.value(value);
|
|
797
876
|
w.flush();
|
|
798
877
|
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* A form-shape property the dedicated form guards judge (and refuse with a
|
|
881
|
+
* more specific message), so the page-binding error leaves it to them.
|
|
882
|
+
* @param {string} name
|
|
883
|
+
* @param {string} tag
|
|
884
|
+
*/
|
|
885
|
+
function isReflectedFormProp(name, tag) {
|
|
886
|
+
return /^(?:action|method|enctype|encoding|formaction|formmethod|formenctype)$/i.test(name)
|
|
887
|
+
&& /^(?:form|button|input)$/.test(tag);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* The dev error for an `@event` or native `.prop` binding in a template no
|
|
892
|
+
* component renders (a page or layout, which never hydrates).
|
|
893
|
+
* @param {boolean} isEvent an `@event` listener (else a `.prop` binding)
|
|
894
|
+
* @param {string} name
|
|
895
|
+
* @param {string} tag
|
|
896
|
+
*/
|
|
897
|
+
function pageBindingError(isEvent, name, tag) {
|
|
898
|
+
const what = isEvent ? `an \`@${name}\` event listener` : `a \`.${name}\` property binding`;
|
|
899
|
+
return `webjs: ${what} on <${tag}> in a page or layout template. Pages and layouts render only on the server and never hydrate, so it is dropped and never runs. `
|
|
900
|
+
+ `Move the interactive markup into a WebComponent and render its tag from the page (its render() hydrates in the browser)`
|
|
901
|
+
+ (!isEvent ? ', or write a plain attribute (\`' + name.toLowerCase() + '=${value}\`) when the value only needs to reach the HTML' : '')
|
|
902
|
+
+ '. This check runs in dev only. See https://webjs.dev/docs/troubleshooting#binding-in-page-template';
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* The value a value-carrying built-in directive stands for in an ATTRIBUTE,
|
|
907
|
+
* property or boolean position, as in Lit (#1686): `guard` renders its
|
|
908
|
+
* callback's result, `until` its first non-promise argument (else the first
|
|
909
|
+
* promise to settle, a rejection reading as no value), `keyed` and `cache`
|
|
910
|
+
* their value. A child position unwraps these in `value()`; an attribute hole
|
|
911
|
+
* used to stringify the directive object to "[object Object]".
|
|
912
|
+
* @param {unknown} v
|
|
913
|
+
* @returns {Promise<unknown>}
|
|
914
|
+
*/
|
|
915
|
+
async function attrValueOf(v) {
|
|
916
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
917
|
+
/** @type any */
|
|
918
|
+
const d = v;
|
|
919
|
+
if (isGuard(d)) v = d.fn();
|
|
920
|
+
else if (isKeyed(d) || isCache(d)) v = d.value;
|
|
921
|
+
else if (isLive(d)) v = d.value;
|
|
922
|
+
else if (isUntil(d)) {
|
|
923
|
+
const sync = d.args.find((a) => !a || typeof a.then !== 'function');
|
|
924
|
+
if (sync !== undefined || d.args.some((a) => !a || typeof a.then !== 'function')) v = sync;
|
|
925
|
+
else if (d.args.length) v = await Promise.race(d.args.map((p) => Promise.resolve(p).catch(() => undefined)));
|
|
926
|
+
else v = undefined;
|
|
927
|
+
} else return v;
|
|
928
|
+
}
|
|
929
|
+
return v;
|
|
930
|
+
}
|
package/src/render-server.d.ts
CHANGED
|
@@ -12,3 +12,6 @@ export function renderToStream(
|
|
|
12
12
|
value: unknown,
|
|
13
13
|
opts?: { ssr?: boolean; suspenseCtx?: SuspenseContext },
|
|
14
14
|
): ReadableStream;
|
|
15
|
+
/** A React-ism in an html template's static strings: `className=` / `htmlFor=` or a capitalised tag. */
|
|
16
|
+
export function findJsxIsm(strings: readonly string[]): { kind: 'attr' | 'tag'; name: string } | null;
|
|
17
|
+
export function jsxIsmMessage(ism: { kind: 'attr' | 'tag'; name: string }): string;
|
package/src/render-server.js
CHANGED
|
@@ -4,3 +4,5 @@
|
|
|
4
4
|
// backs the published `@webjsdev/core/server` subpath, so re-exporting it would
|
|
5
5
|
// widen the package's public API for a helper no consumer asked for.
|
|
6
6
|
export { renderToString, renderToStream } from './render-server/stream.js';
|
|
7
|
+
// The JSX-in-template scan, shared with `webjs check` (`no-jsx-attributes`).
|
|
8
|
+
export { findJsxIsm, jsxIsmMessage } from './jsx-guard.js';
|
package/src/repeat.d.ts
CHANGED
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
// exported, so it cannot be named, and no consumer constructs one by hand.
|
|
4
4
|
export interface RepeatDirective<T> {
|
|
5
5
|
items: T[];
|
|
6
|
-
keyFn: (item: T, i: number) =>
|
|
6
|
+
keyFn: (item: T, i: number) => unknown;
|
|
7
7
|
templateFn: (item: T, i: number) => unknown;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function repeat<T>(
|
|
11
11
|
items: Iterable<T>,
|
|
12
|
-
keyFn: (item: T, i: number) =>
|
|
12
|
+
keyFn: (item: T, i: number) => unknown,
|
|
13
|
+
templateFn: (item: T, i: number) => unknown,
|
|
14
|
+
): RepeatDirective<T>;
|
|
15
|
+
/** lit's two-argument form: no key function, so each item is keyed by its index. */
|
|
16
|
+
export function repeat<T>(
|
|
17
|
+
items: Iterable<T>,
|
|
13
18
|
templateFn: (item: T, i: number) => unknown,
|
|
14
19
|
): RepeatDirective<T>;
|
|
15
20
|
export function isRepeat(x: unknown): x is RepeatDirective<unknown>;
|
package/src/repeat.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* html`<ul>${repeat(items, (it) => it.id, (it) => html`<li>${it.title}</li>`)}</ul>`;
|
|
8
8
|
* ```
|
|
9
9
|
*
|
|
10
|
-
* Without `repeat()`, an array
|
|
11
|
-
*
|
|
10
|
+
* Without `repeat()`, an array updates positionally: the DOM for item N is
|
|
11
|
+
* reused for whatever is now at index N, so focus, selection and component
|
|
12
|
+
* state follow the position, not the item. With `repeat()`, the client
|
|
13
|
+
* renderer maintains a
|
|
12
14
|
* `Map<key, TemplateInstance>`: matched items update in place, new items
|
|
13
15
|
* mount fresh, removed items unmount, and the surviving DOM nodes are moved
|
|
14
16
|
* (not recreated) when the order changes.
|
|
@@ -16,19 +18,31 @@
|
|
|
16
18
|
* Keys must be stable and unique within the call. Bad keys (array indices)
|
|
17
19
|
* defeat the purpose; prefer a stable id from the data.
|
|
18
20
|
*
|
|
21
|
+
* The two-argument form, `repeat(items, (it) => html`...`)`, is lit's too: with
|
|
22
|
+
* no key function each item is keyed by its INDEX, which makes it a plain
|
|
23
|
+
* positional list (no DOM moves on reorder). Pass a key function whenever item
|
|
24
|
+
* identity has to follow the data.
|
|
25
|
+
*
|
|
19
26
|
* On the server this marker is just iterated: order matters, keys don't.
|
|
20
27
|
*/
|
|
21
28
|
|
|
22
29
|
const REPEAT = Symbol.for('webjs.repeat');
|
|
23
30
|
|
|
31
|
+
/** The key of an item `repeat(items, template)` was given no key function for. */
|
|
32
|
+
const indexKey = (/** @type {unknown} */ _item, /** @type {number} */ i) => i;
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
35
|
* @template T
|
|
26
36
|
* @param {Iterable<T>} items
|
|
27
|
-
* @param {(item: T, i: number) =>
|
|
28
|
-
*
|
|
37
|
+
* @param {((item: T, i: number) => unknown)} keyFnOrTemplate the key function,
|
|
38
|
+
* or, when `templateFn` is omitted, the template function (lit's two-argument form)
|
|
39
|
+
* @param {(item: T, i: number) => unknown} [templateFn]
|
|
29
40
|
*/
|
|
30
|
-
export function repeat(items,
|
|
31
|
-
|
|
41
|
+
export function repeat(items, keyFnOrTemplate, templateFn) {
|
|
42
|
+
if (templateFn === undefined) {
|
|
43
|
+
return { [REPEAT]: true, items: [...items], keyFn: indexKey, templateFn: keyFnOrTemplate };
|
|
44
|
+
}
|
|
45
|
+
return { [REPEAT]: true, items: [...items], keyFn: keyFnOrTemplate, templateFn };
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
/** @param {unknown} x */
|
|
@@ -224,9 +224,25 @@ export function diffElementInPlace(dst, src) {
|
|
|
224
224
|
}
|
|
225
225
|
// Update attributes from src; remove ones not in src.
|
|
226
226
|
const srcAttrs = new Set();
|
|
227
|
+
// A server-passed `.prop` binding on a reused, upgraded component (#1686):
|
|
228
|
+
// its `data-webjs-prop-*` attribute was read once at upgrade and stripped,
|
|
229
|
+
// so writing the new value as an attribute changed nothing and the property
|
|
230
|
+
// stayed stale after a same-page update. A binding whose value changed is
|
|
231
|
+
// handed to the component to decode; an unchanged one is left alone, so a
|
|
232
|
+
// value the component changed locally survives, as in Lit.
|
|
233
|
+
const hydrated = isHydratedComponent(dst) && typeof (/** @type any */ (dst))._hydratePropAttrs === 'function';
|
|
234
|
+
let propsChanged = false;
|
|
227
235
|
for (const attr of src.attributes) {
|
|
228
236
|
srcAttrs.add(attr.name);
|
|
229
237
|
if (LIVE_ATTRS.has(attr.name)) continue;
|
|
238
|
+
if (hydrated && attr.name.startsWith('data-webjs-prop-')) {
|
|
239
|
+
const seen = /** @type any */ (dst).__webjsPropAttrValues;
|
|
240
|
+
if (!seen || seen.get(attr.name) !== attr.value) {
|
|
241
|
+
dst.setAttribute(attr.name, attr.value);
|
|
242
|
+
propsChanged = true;
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
230
246
|
// The serialized-restore stamp is a message to a NOT-YET-UPGRADED
|
|
231
247
|
// element's connectedCallback; copying it onto a live reused host would
|
|
232
248
|
// leave a consume-once marker lingering in the live DOM forever. Note
|
|
@@ -243,6 +259,7 @@ export function diffElementInPlace(dst, src) {
|
|
|
243
259
|
if (LIVE_ATTRS.has(attr.name)) continue;
|
|
244
260
|
if (!srcAttrs.has(attr.name)) dst.removeAttribute(attr.name);
|
|
245
261
|
}
|
|
262
|
+
if (propsChanged) /** @type any */ (dst)._hydratePropAttrs();
|
|
246
263
|
// For form-control-like elements, preserve live IDL state.
|
|
247
264
|
// (`value`, `checked`, `open`, etc.: see LIVE_ATTRS below for full list.)
|
|
248
265
|
// The attribute version is skipped above; we deliberately do nothing
|
|
@@ -15,7 +15,8 @@ import { restoreOptimistic } from './dom-differ.js';
|
|
|
15
15
|
import { parseHTML } from './dom-parse.js';
|
|
16
16
|
import { clearFrameBusy, markFrameBusy } from './frames.js';
|
|
17
17
|
import { handleNavigationError } from './nav-error.js';
|
|
18
|
-
import { navigate } from './navigator.js';
|
|
18
|
+
import { navigate, revalidate } from './navigator.js';
|
|
19
|
+
import { parseRefreshHeader } from '../refresh-header.js';
|
|
19
20
|
import { prefetchTake } from './prefetch.js';
|
|
20
21
|
import { currentNavigationToken } from './state.js';
|
|
21
22
|
import { readStreamedShell, streamBoundariesProgressively } from './stream.js';
|
|
@@ -53,7 +54,7 @@ import { _swapCommit, applySwap } from './swap.js';
|
|
|
53
54
|
* top on a forward navigation (#1436), from `data-preserve-scroll` on the
|
|
54
55
|
* link or form, or from `navigate(url, { scroll: false })`. It suppresses only
|
|
55
56
|
* the scroll-to-top writes, never the hash-anchor scroll.
|
|
56
|
-
* @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean }>}
|
|
57
|
+
* @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean, devError?: boolean }>}
|
|
57
58
|
* The fetch outcome, so a caller (the form-submission busy/event lifecycle)
|
|
58
59
|
* can report whether the submission settled as a success, an error, or an
|
|
59
60
|
* abort. `ok` mirrors `response.ok` for an HTTP response (a 422 validation
|
|
@@ -72,7 +73,62 @@ import { _swapCommit, applySwap } from './swap.js';
|
|
|
72
73
|
* degradation to a hard navigation from an importmap mismatch or a poisoned
|
|
73
74
|
* boundary scan). A caller deciding whether to fall back to a full page load
|
|
74
75
|
* wants `applied`; one reporting the submission's success wants `ok`.
|
|
76
|
+
*
|
|
77
|
+
* `devError` is set for a `refresh` answered with `X-Webjs-Dev-Error: 1`
|
|
78
|
+
* (#1689): the dev render failed, nothing was applied, and the page on
|
|
79
|
+
* screen is meant to stay as it is under the dev overlay.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Whether two urls name the same document (origin, path and query; the hash
|
|
83
|
+
* is a position within it).
|
|
84
|
+
* @param {string} a @param {string} b
|
|
85
|
+
*/
|
|
86
|
+
function sameDocumentUrl(a, b) {
|
|
87
|
+
try {
|
|
88
|
+
const x = new URL(a, location.href);
|
|
89
|
+
const y = new URL(b, location.href);
|
|
90
|
+
return x.origin === y.origin && x.pathname === y.pathname && x.search === y.search;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The focused element and its text selection, for a same-page update.
|
|
98
|
+
* @returns {{ el: Element, start: number | null, end: number | null } | null}
|
|
75
99
|
*/
|
|
100
|
+
function captureFocus() {
|
|
101
|
+
const el = document.activeElement;
|
|
102
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
103
|
+
/** @type any */
|
|
104
|
+
const input = el;
|
|
105
|
+
let start = null;
|
|
106
|
+
let end = null;
|
|
107
|
+
try { start = input.selectionStart ?? null; end = input.selectionEnd ?? null; } catch { /* not a text control */ }
|
|
108
|
+
return { el, start, end };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Give focus back to the element that had it, when the morph kept the node.
|
|
113
|
+
* @param {{ el: Element, start: number | null, end: number | null }} state
|
|
114
|
+
*/
|
|
115
|
+
function restoreFocus(state) {
|
|
116
|
+
const { el, start, end } = state;
|
|
117
|
+
if (!el.isConnected || document.activeElement === el) return;
|
|
118
|
+
/** @type any */
|
|
119
|
+
const target = el;
|
|
120
|
+
if (typeof target.focus !== 'function') return;
|
|
121
|
+
// WebKit scrolls a text control into view on setSelectionRange even after a
|
|
122
|
+
// preventScroll focus, so the window offset is put back afterwards.
|
|
123
|
+
const x = window.scrollX;
|
|
124
|
+
const y = window.scrollY;
|
|
125
|
+
target.focus({ preventScroll: true });
|
|
126
|
+
if (start !== null && end !== null) {
|
|
127
|
+
try { target.setSelectionRange(start, end); } catch { /* not a text control */ }
|
|
128
|
+
}
|
|
129
|
+
if (window.scrollX !== x || window.scrollY !== y) window.scrollTo({ left: x, top: y, behavior: 'instant' });
|
|
130
|
+
}
|
|
131
|
+
|
|
76
132
|
export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, opts) {
|
|
77
133
|
method = method || 'GET';
|
|
78
134
|
const refresh = (opts && opts.refresh) || undefined;
|
|
@@ -176,11 +232,27 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
176
232
|
const resp = await fetch(href, init);
|
|
177
233
|
respStatus = resp.status;
|
|
178
234
|
respOk = resp.ok;
|
|
235
|
+
// A dev render that reported an error (#1689). The server marks it, and
|
|
236
|
+
// the dev overlay is already up describing it. A refresh leaves the page
|
|
237
|
+
// that last rendered on screen under the overlay, as Next does, so the
|
|
238
|
+
// edit that fixes the error refreshes it in place; applying the error page
|
|
239
|
+
// would share no boundary with it and hard-navigate, and so would the fix.
|
|
240
|
+
// Only `webjs dev` sends the header.
|
|
241
|
+
if (refresh && resp.headers.get('x-webjs-dev-error') === '1') {
|
|
242
|
+
try { await resp.body?.cancel(); } catch (_) { /* already consumed */ }
|
|
243
|
+
return { ok: false, status: respStatus, aborted: false, applied: false, devError: true };
|
|
244
|
+
}
|
|
179
245
|
// `fetch` follows a 303 transparently and its headers are then unreadable,
|
|
180
246
|
// so this lands on a non-redirect response: the 422 failure re-render, or
|
|
181
247
|
// any form response that answers in place.
|
|
182
248
|
const invalidated = parseTagHeader(resp.headers.get('x-webjs-invalidate'));
|
|
183
249
|
if (invalidated.length) markStale(invalidated);
|
|
250
|
+
// `revalidatePath()` in a form action (a 422 re-render, or any response
|
|
251
|
+
// answered in place): drop the router's cached copies of those paths.
|
|
252
|
+
for (const e of parseRefreshHeader(resp.headers.get('x-webjs-refresh'))) {
|
|
253
|
+
if (e.layout || e.path.includes('[')) revalidate();
|
|
254
|
+
else revalidate(e.path);
|
|
255
|
+
}
|
|
184
256
|
const ctype = resp.headers.get('content-type') || '';
|
|
185
257
|
const isHTML = /^text\/html\b/i.test(ctype);
|
|
186
258
|
const isStream = ctype.toLowerCase().indexOf(STREAM_MIME) === 0;
|
|
@@ -342,14 +414,22 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
342
414
|
// (see the `applied` comment below). Keeping the tail call preserves them byte
|
|
343
415
|
// for byte, so this commit changes the ORDER on committing swaps and nothing
|
|
344
416
|
// else.
|
|
417
|
+
// A write that lands back on the page it was submitted from (a validation
|
|
418
|
+
// re-render, or a PRG redirect to the same url) updates that page in place,
|
|
419
|
+
// the way a Next.js server action does after revalidatePath (#1686): the
|
|
420
|
+
// history entry is replaced rather than duplicated, the scroll position is
|
|
421
|
+
// kept, and the focused control keeps focus when it survives the morph.
|
|
422
|
+
const samePage = mutation && !refresh && sameDocumentUrl(finalUrl, location.href);
|
|
345
423
|
let historyRecorded = false;
|
|
346
424
|
const recordHistoryNow = recordHistory
|
|
347
425
|
? () => {
|
|
348
426
|
if (historyRecorded) return;
|
|
349
427
|
historyRecorded = true;
|
|
350
|
-
history.
|
|
428
|
+
if (samePage) history.replaceState(history.state, '', finalUrl);
|
|
429
|
+
else history.pushState(null, '', finalUrl);
|
|
351
430
|
}
|
|
352
431
|
: null;
|
|
432
|
+
const focusState = samePage ? captureFocus() : null;
|
|
353
433
|
|
|
354
434
|
const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh || (mutation ? 'layouts' : undefined), recordHistoryNow);
|
|
355
435
|
// The temporary stylesheet copies are released once the swap has COMMITTED:
|
|
@@ -357,6 +437,7 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
357
437
|
// lands a frame later. A copy with no twin in the result (a discarded or
|
|
358
438
|
// uncommitted swap) goes as soon as the commit settles.
|
|
359
439
|
if (sheets) _swapCommit.then(() => releaseIncomingStylesheets(sheets), () => releaseIncomingStylesheets(sheets));
|
|
440
|
+
if (focusState) _swapCommit.then(() => { if (myToken === currentNavigationToken) restoreFocus(focusState); }, () => {});
|
|
360
441
|
// `'none'` means applySwap returned WITHOUT committing anything: the frame the
|
|
361
442
|
// response was for is missing, or it degraded to a hard navigation (an
|
|
362
443
|
// importmap/build mismatch, a poisoned boundary scan). The page is not left
|
|
@@ -427,7 +508,7 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
427
508
|
// `#section` link is exactly where an app's `scroll-behavior: smooth`
|
|
428
509
|
// is wanted, and native browsers animate it too.
|
|
429
510
|
target.scrollIntoView();
|
|
430
|
-
} else if (!preserveScroll) {
|
|
511
|
+
} else if (!preserveScroll && !samePage) {
|
|
431
512
|
// Scroll-to-top on a forward nav. behavior:'instant' so an app-level
|
|
432
513
|
// `scroll-behavior: smooth` does not animate it (match native nav).
|
|
433
514
|
warnIfSmoothScrollOnHtml();
|
|
@@ -454,7 +535,7 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
454
535
|
}
|
|
455
536
|
|
|
456
537
|
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
|
|
457
|
-
return { ok: respOk, status: respStatus, aborted: false, applied };
|
|
538
|
+
return { ok: respOk, status: respStatus, aborted: false, applied, samePage };
|
|
458
539
|
} finally {
|
|
459
540
|
// Clear the frame's busy state on every exit path (the early returns
|
|
460
541
|
// above all unwind through here). No-op when this was not a frame nav.
|