jq79 0.5.7 → 0.5.9
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/jq79.cjs +13 -12
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +3 -0
- package/dist/jq79.global.js +13 -12
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +13 -12
- package/dist/jq79.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +317 -46
package/package.json
CHANGED
package/src/jq79.ts
CHANGED
|
@@ -19,6 +19,12 @@ type TemplateNode = {
|
|
|
19
19
|
tag: string
|
|
20
20
|
attrs: Record<string, string>
|
|
21
21
|
children: (TemplateNode | string)[]
|
|
22
|
+
// the tag as the author capitalized it, present only when they wrote it
|
|
23
|
+
// uppercase-initial - i.e. when they meant a component. `tag` cannot answer
|
|
24
|
+
// this: the HTML parser lowercases it, so the claim is captured before the
|
|
25
|
+
// parse (see stampComponentTag) and lifted off attrs here, where it stops
|
|
26
|
+
// looking like an attribute to every loop downstream
|
|
27
|
+
component?: string
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
type TagBlock = {
|
|
@@ -45,20 +51,29 @@ const elementAttrs = (el: Element): Record<string, string> =>
|
|
|
45
51
|
// they are not in the AST at all - which is where slot content is written
|
|
46
52
|
// (<template :slot.name>), and why a nested <template> used to render as an
|
|
47
53
|
// empty element whatever was inside it
|
|
48
|
-
const elementToAST = (el: Element): TemplateNode =>
|
|
49
|
-
|
|
50
|
-
attrs:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
const elementToAST = (el: Element): TemplateNode => {
|
|
55
|
+
const attrs = elementAttrs(el)
|
|
56
|
+
// the pre-parse stamp becomes a field and leaves attrs entirely: it is not a
|
|
57
|
+
// prop, not a directive and not an attribute, and every loop that walks attrs
|
|
58
|
+
// would otherwise need to know its name
|
|
59
|
+
const component = attrs[COMPONENT_TAG_ATTR]
|
|
60
|
+
delete attrs[COMPONENT_TAG_ATTR]
|
|
61
|
+
return {
|
|
62
|
+
tag: el.tagName.toLowerCase(),
|
|
63
|
+
attrs,
|
|
64
|
+
...(component === undefined ? {} : { component }),
|
|
65
|
+
children: Array.from((el instanceof HTMLTemplateElement ? el.content : el).childNodes).flatMap((node): (TemplateNode | string)[] => {
|
|
66
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
67
|
+
const text = node.textContent ?? ""
|
|
68
|
+
return text ? [text] : []
|
|
69
|
+
}
|
|
70
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
71
|
+
return [elementToAST(node as Element)]
|
|
72
|
+
}
|
|
73
|
+
return []
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
62
77
|
|
|
63
78
|
// evaluated with `with` (rather than passing scope keys as positional params)
|
|
64
79
|
// so only the identifiers an expression actually references are read from
|
|
@@ -383,6 +398,32 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
383
398
|
return null
|
|
384
399
|
}
|
|
385
400
|
|
|
401
|
+
// every name a tag *could* have resolved to, walking the same chain
|
|
402
|
+
// findComponentKey does. Deduped and sorted, because the chain can hold one
|
|
403
|
+
// name twice (a prop shadowing a sibling) and the order it comes out in is the
|
|
404
|
+
// prototype's, which means nothing to a reader scanning for their typo
|
|
405
|
+
const componentsInScope = (scope: Record<string, any>): string[] => {
|
|
406
|
+
const names = new Set<string>()
|
|
407
|
+
for (let obj: any = scope; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
|
|
408
|
+
for (const key of Object.keys(obj)) if (/^[A-Z]/.test(key)) names.add(key)
|
|
409
|
+
}
|
|
410
|
+
return [...names].sort()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// a tag whose name resolves to no component, once nothing can still supply one.
|
|
414
|
+
// The error names what *is* in scope: the mistake is nearly always a typo or a
|
|
415
|
+
// missing import, and both are one glance from the list. "(none)" is its own
|
|
416
|
+
// answer - it says the component has no components at all, which points at the
|
|
417
|
+
// import rather than at the spelling
|
|
418
|
+
const unresolvedComponent = (tag: string, scope: Record<string, any>): Error => {
|
|
419
|
+
const names = componentsInScope(scope)
|
|
420
|
+
return new Error(
|
|
421
|
+
`jq79: <${tag}> is not defined - no component of that name is in scope, and nothing renders here. ` +
|
|
422
|
+
`Import it in a :setup script, declare it as a prop, or add a <template name="${tag}"> to this file. ` +
|
|
423
|
+
`In scope: ${names.length ? names.join(", ") : "(none)"}.`
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
386
427
|
// how deep a component may nest inside itself before the runtime calls it a
|
|
387
428
|
// cycle. Deeper than any real tree, shallower than the JS stack: a truncated
|
|
388
429
|
// render with an error on the console beats a stack overflow with none
|
|
@@ -1025,6 +1066,32 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1025
1066
|
|
|
1026
1067
|
const el = document.createElement(node.tag)
|
|
1027
1068
|
|
|
1069
|
+
// <UserCrad /> - written as a component (node.component), resolving to no
|
|
1070
|
+
// component, and not an element either. Nothing else on the page can supply
|
|
1071
|
+
// the name once every script has settled, so this renders no markup, no
|
|
1072
|
+
// styles, no children and no script, forever, and says so by throwing rather
|
|
1073
|
+
// than leaving a hole where a region of the page was meant to be.
|
|
1074
|
+
//
|
|
1075
|
+
// All three conditions carry weight. Without the capitalization <lable> and
|
|
1076
|
+
// <svg> would be fatal (createElement builds SVG names in the HTML namespace,
|
|
1077
|
+
// so an <svg> is an HTMLUnknownElement too); without the element check <DIV>
|
|
1078
|
+
// would be, though it renders a perfectly good div; and without the pending
|
|
1079
|
+
// count a factory that awaits $mounted() before returning its components
|
|
1080
|
+
// could never render one, which is exactly what the watcher below is for.
|
|
1081
|
+
//
|
|
1082
|
+
// An *absent* count is a fourth case, and it is not zero: renderComponent()
|
|
1083
|
+
// renders a template against a store somebody else owns and assembles, so
|
|
1084
|
+
// nothing there has finished and nothing says a key can't still be written
|
|
1085
|
+
// in. The claim being tested is a component's claim about its own scripts,
|
|
1086
|
+
// and where none was made the tag waits for the upgrade, as it always has.
|
|
1087
|
+
//
|
|
1088
|
+
// Written without a local for the count because renderNode is on the stack
|
|
1089
|
+
// for the whole of the subtree below it, so a slot here is a slot per level
|
|
1090
|
+
// of a component nested inside itself - see renderWith
|
|
1091
|
+
if (node.component && el instanceof HTMLUnknownElement && ((scope as any)[PENDING_SCRIPTS] as PendingScripts | undefined)?.count === 0) {
|
|
1092
|
+
throw unresolvedComponent(node.component, scope)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1028
1095
|
// a tag that isn't standard HTML but has no matching scope key *yet* may be
|
|
1029
1096
|
// a component that arrives later (e.g. an async factory script exposing an
|
|
1030
1097
|
// imported component after `await`). Watch for the key: the effect tracks
|
|
@@ -1527,6 +1594,36 @@ const SLOT_TAG_RE = /^slot\./i
|
|
|
1527
1594
|
const kebabTagName = (tag: string): string =>
|
|
1528
1595
|
SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
|
|
1529
1596
|
|
|
1597
|
+
// the same pass records what it declined to rewrite. An uppercase-initial tag
|
|
1598
|
+
// is a claim about a component: HTML's own elements are matched
|
|
1599
|
+
// case-insensitively but nobody writes <DIV> by accident, and a custom element
|
|
1600
|
+
// may not be spelled that way at all. So <UserCard> is a name the author
|
|
1601
|
+
// expected to resolve - which is what lets renderNode throw when it doesn't
|
|
1602
|
+
// (see unresolvedComponent).
|
|
1603
|
+
//
|
|
1604
|
+
// Carried in a *value* rather than left in the tag name, because the value is
|
|
1605
|
+
// the one place the HTML parser preserves case - the same move expandPropsSpread
|
|
1606
|
+
// makes for `...userData`, and for the same reason. elementToAST lifts it
|
|
1607
|
+
// straight off attrs into a field, so no attribute loop downstream ever sees
|
|
1608
|
+
// it - and since that lift is unconditional, the name has to be one no author
|
|
1609
|
+
// would write: a plain `:component` would eat the prop of that name off
|
|
1610
|
+
// <Card :component="Widget" />
|
|
1611
|
+
const COMPONENT_TAG_ATTR = ":jq79-component"
|
|
1612
|
+
const COMPONENT_TAG_RE = /^[A-Z]/
|
|
1613
|
+
|
|
1614
|
+
// appends the stamp inside the tag, *before* a self-closing slash: this pass
|
|
1615
|
+
// runs first and expandSelfClosingTags still has to recognize the `/>` that
|
|
1616
|
+
// OPEN_TAG_RE swept into the attributes. A slash inside a quoted value can't be
|
|
1617
|
+
// mistaken for it - only a trailing one is matched
|
|
1618
|
+
const TRAILING_SLASH_RE = /\/\s*$/
|
|
1619
|
+
|
|
1620
|
+
const stampComponentTag = (tag: string, attrs: string): string => {
|
|
1621
|
+
if (!COMPONENT_TAG_RE.test(tag)) return attrs
|
|
1622
|
+
const stamp = ` ${COMPONENT_TAG_ATTR}="${tag}"`
|
|
1623
|
+
const slash = TRAILING_SLASH_RE.exec(attrs)
|
|
1624
|
+
return slash ? `${attrs.slice(0, slash.index)}${stamp}${slash[0]}` : `${attrs}${stamp}`
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1530
1627
|
const expandNameCase = (src: string): string =>
|
|
1531
1628
|
src
|
|
1532
1629
|
.split(RAW_BLOCK_RE)
|
|
@@ -1538,7 +1635,7 @@ const expandNameCase = (src: string): string =>
|
|
|
1538
1635
|
const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
|
|
1539
1636
|
name === undefined ? whole : `${space}${camelToKebab(name)}`
|
|
1540
1637
|
)
|
|
1541
|
-
return `<${kebabTagName(tag)}${rewritten}>`
|
|
1638
|
+
return `<${kebabTagName(tag)}${stampComponentTag(tag, rewritten)}>`
|
|
1542
1639
|
})
|
|
1543
1640
|
.replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
|
|
1544
1641
|
)
|
|
@@ -1806,6 +1903,11 @@ const resolveSpecifier = (spec: string, filename: string | undefined): string =>
|
|
|
1806
1903
|
// or devtools shows only one of them)
|
|
1807
1904
|
type ScriptLocation = { filename?: string; index?: number }
|
|
1808
1905
|
|
|
1906
|
+
// what running a script tells its caller: the promise it settles through, and
|
|
1907
|
+
// whether it already finished on this stack. `sync` is the fast path the render
|
|
1908
|
+
// gate is built on - see runSetupScript
|
|
1909
|
+
type ScriptRun = { settled: Promise<unknown>; sync: boolean }
|
|
1910
|
+
|
|
1809
1911
|
// nothing to name an inline component's scripts after, so they stay anonymous
|
|
1810
1912
|
const sourceUrlComment = (filename: string | undefined, index: number): string =>
|
|
1811
1913
|
filename ? `\n//# sourceURL=${filename}?jq79-script=${index}` : ""
|
|
@@ -1851,23 +1953,36 @@ const releaseStyle = (content: string) => {
|
|
|
1851
1953
|
// normally. get/set are deliberately not trapped: they default-forward to
|
|
1852
1954
|
// `scope` (the reactive proxy), preserving tracking and notify.
|
|
1853
1955
|
// The body is wrapped in an async IIFE so top-level `await` works: everything
|
|
1854
|
-
// up to the first await runs synchronously
|
|
1855
|
-
//
|
|
1856
|
-
|
|
1956
|
+
// up to the first await runs synchronously, and later assignments update the
|
|
1957
|
+
// DOM reactively when they happen.
|
|
1958
|
+
//
|
|
1959
|
+
// Returns whether the body ran to completion synchronously, plus the promise it
|
|
1960
|
+
// settles through. renderWith needs the *synchronous* answer - a script that
|
|
1961
|
+
// finished in this turn cannot hold anything up, so the template can render on
|
|
1962
|
+
// this stack exactly as it always has (see the render gate). Asking the promise
|
|
1963
|
+
// instead would defer every render by a microtask, including the overwhelmingly
|
|
1964
|
+
// common case of a script with no await in it at all.
|
|
1965
|
+
//
|
|
1966
|
+
// The flag is set on the code's last line, after the `with` block rather than
|
|
1967
|
+
// inside it, so the scope proxy never sees the name - and appended, so the
|
|
1968
|
+
// author's line numbers (which sourceUrlComment maps for devtools) don't shift
|
|
1969
|
+
const runSetupScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}): ScriptRun => {
|
|
1857
1970
|
// instanceHelpers are per-component-instance additions (e.g. $emit, which
|
|
1858
1971
|
// is bound to this instance's DOM position)
|
|
1859
1972
|
const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
|
|
1860
1973
|
const scriptScope = new Proxy(scope, {
|
|
1861
1974
|
has: (target, key) =>
|
|
1862
|
-
key !== "$__effect" && key !== "$__import" &&
|
|
1975
|
+
key !== "$__effect" && key !== "$__import" && key !== "$__state" &&
|
|
1863
1976
|
(Reflect.has(target, key) || !(key in globalThis) && !(key in helpers)),
|
|
1864
1977
|
})
|
|
1978
|
+
const state: { done?: boolean } = {}
|
|
1865
1979
|
const result: Promise<void> = new Function(
|
|
1866
|
-
"$scope", "$__effect", "$__import", ...Object.keys(helpers),
|
|
1867
|
-
`return (async () => { with ($scope) { ${code} } })()${sourceUrlComment(at.filename, at.index ?? 0)}`
|
|
1868
|
-
)(scriptScope, effect, importer, ...Object.values(helpers))
|
|
1980
|
+
"$scope", "$__effect", "$__import", "$__state", ...Object.keys(helpers),
|
|
1981
|
+
`return (async () => { with ($scope) { ${code} }\n;$__state.done = true })()${sourceUrlComment(at.filename, at.index ?? 0)}`
|
|
1982
|
+
)(scriptScope, effect, importer, state, ...Object.values(helpers))
|
|
1869
1983
|
result.catch(error => console.error("jq79: error in :setup script", error))
|
|
1870
1984
|
trackScript(result)
|
|
1985
|
+
return { settled: result, sync: state.done === true }
|
|
1871
1986
|
}
|
|
1872
1987
|
|
|
1873
1988
|
// puts a component's declared props on the store, before any script runs and
|
|
@@ -2031,6 +2146,21 @@ const siblingsInScope = (
|
|
|
2031
2146
|
// it too) without ever showing up as data
|
|
2032
2147
|
const UNFILLED_PROPS = Symbol("jq79.unfilledProps")
|
|
2033
2148
|
|
|
2149
|
+
// how many of this render generation's scripts have yet to settle, as a live
|
|
2150
|
+
// box rather than a snapshot. Rides the scope chain like UNFILLED_PROPS, and
|
|
2151
|
+
// for one reader: a <Tag> naming no component in scope is only a mistake once
|
|
2152
|
+
// nothing is left that could still supply the name.
|
|
2153
|
+
//
|
|
2154
|
+
// The count is not the render gate. A script that called $mounted() released
|
|
2155
|
+
// the template and is still running - that is the whole point of the call - so
|
|
2156
|
+
// at paint time this can be non-zero, and a name arriving from a factory that
|
|
2157
|
+
// awaited $mounted() is exactly the case the count keeps quiet. Read live, so
|
|
2158
|
+
// an :if that opens after everything settled is judged against the scripts as
|
|
2159
|
+
// they are then, not as they were at the first paint
|
|
2160
|
+
const PENDING_SCRIPTS = Symbol("jq79.pendingScripts")
|
|
2161
|
+
|
|
2162
|
+
type PendingScripts = { count: number }
|
|
2163
|
+
|
|
2034
2164
|
// default-import interop for factory scripts: real modules expose .default,
|
|
2035
2165
|
// while importing an .html component resolves to the Component79 itself
|
|
2036
2166
|
const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
|
|
@@ -2042,7 +2172,7 @@ const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.def
|
|
|
2042
2172
|
// synchronous body invokes the factory before the first render, matching
|
|
2043
2173
|
// setup-script timing; bodies with top-level await (static imports included)
|
|
2044
2174
|
// resolve later and the template updates reactively
|
|
2045
|
-
const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}) => {
|
|
2175
|
+
const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}): ScriptRun => {
|
|
2046
2176
|
const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
|
|
2047
2177
|
const $__exports: { default?: (props: Record<string, any>, ctx: Record<string, any>) => any; done?: boolean } = {}
|
|
2048
2178
|
const result: Promise<void> = new Function(
|
|
@@ -2083,8 +2213,14 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
|
|
|
2083
2213
|
// tracked through the merge, not just the module body: a factory's names
|
|
2084
2214
|
// reach the store in `merge`, and a template expression that reads one before
|
|
2085
2215
|
// then is not an authoring mistake (see reportExprError)
|
|
2086
|
-
|
|
2216
|
+
const settled = result.then(invoke, logError)
|
|
2217
|
+
trackScript(settled)
|
|
2087
2218
|
if ($__exports.done) invoke() // fully-sync body: factory runs before first render
|
|
2219
|
+
// sync only if the bindings are already on the store: a factory whose body
|
|
2220
|
+
// finished but whose *factory* returned a promise (an async factory, or one
|
|
2221
|
+
// that awaits $mounted()) still has names on the way, and the render gate
|
|
2222
|
+
// must treat it as pending rather than race its merge
|
|
2223
|
+
return { settled, sync: $__exports.done === true && merging === undefined }
|
|
2088
2224
|
}
|
|
2089
2225
|
|
|
2090
2226
|
// ---------------------------------------------------------------------------
|
|
@@ -2183,6 +2319,32 @@ export const enableHotReload = (): void => {
|
|
|
2183
2319
|
|
|
2184
2320
|
type EmitListener = (event: CustomEvent, payload: any) => void
|
|
2185
2321
|
|
|
2322
|
+
// how long a first render may sit behind its scripts before the console says so
|
|
2323
|
+
const STUCK_RENDER_DELAY = 3000
|
|
2324
|
+
|
|
2325
|
+
// a script that neither returns nor calls $mounted() holds the template
|
|
2326
|
+
// forever, and the failure looks like nothing at all: no error, no markup, a
|
|
2327
|
+
// component indistinguishable from one nobody mounted. So the wait is loud
|
|
2328
|
+
// after a few seconds - and it keeps waiting, because rendering on a timer
|
|
2329
|
+
// would make the moment of the first render depend on the machine it runs on.
|
|
2330
|
+
//
|
|
2331
|
+
// Armed only on the deferred path, so a page of synchronous components creates
|
|
2332
|
+
// no timers at all
|
|
2333
|
+
const warnIfStuck = (component: Component79, gates: Promise<void>[]) => {
|
|
2334
|
+
const timer = setTimeout(() => {
|
|
2335
|
+
console.warn(
|
|
2336
|
+
`jq79: ${component.name ? `<${component.name}>` : "a component"}${component.filename ? ` (${component.filename})` : ""} ` +
|
|
2337
|
+
`has been waiting ${STUCK_RENDER_DELAY / 1000}s for a :setup script and has rendered nothing. ` +
|
|
2338
|
+
"The template waits until every script returns or calls $mounted() - add an " +
|
|
2339
|
+
"await $mounted() above the slow part to render first and fill in after."
|
|
2340
|
+
)
|
|
2341
|
+
}, STUCK_RENDER_DELAY)
|
|
2342
|
+
// unref where it exists (node/vitest): a pending timer must not be what keeps
|
|
2343
|
+
// a process alive. Browsers have no such notion and no such need
|
|
2344
|
+
;(timer as any)?.unref?.()
|
|
2345
|
+
Promise.all(gates).then(() => clearTimeout(timer))
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2186
2348
|
const fetchComponent = async (url: string): Promise<Component79> => {
|
|
2187
2349
|
const response = await fetch(url)
|
|
2188
2350
|
if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
|
|
@@ -2253,6 +2415,11 @@ export class Component79 {
|
|
|
2253
2415
|
private mountRoot: Element | ShadowRoot | DocumentFragment | null = null
|
|
2254
2416
|
// settles the $mounted() promise handed to this render generation's scripts
|
|
2255
2417
|
private resolveMounted: (() => void) | null = null
|
|
2418
|
+
// whether this generation's template has been built. A render held back by a
|
|
2419
|
+
// script (see the gate in renderWith) has markers but no nodes, and $mounted()
|
|
2420
|
+
// must not resolve on attach alone - a script awaiting it would wake to an
|
|
2421
|
+
// empty component and find nothing to query
|
|
2422
|
+
private renderDone = false
|
|
2256
2423
|
// instance-level listeners for $emit events, registered with on(). Kept
|
|
2257
2424
|
// outside the render generation so they survive re-render and destroy()
|
|
2258
2425
|
private emitListeners = new Map<string, Set<EmitListener>>()
|
|
@@ -2345,7 +2512,7 @@ export class Component79 {
|
|
|
2345
2512
|
if (shadow) this.styleEls.forEach(el => parent.insertBefore(el, before))
|
|
2346
2513
|
parent.insertBefore(this.content!, before)
|
|
2347
2514
|
this.mountRoot = parent
|
|
2348
|
-
this.
|
|
2515
|
+
this.settleMounted()
|
|
2349
2516
|
return true
|
|
2350
2517
|
}
|
|
2351
2518
|
|
|
@@ -2411,6 +2578,14 @@ export class Component79 {
|
|
|
2411
2578
|
// (`<footer :if="$slots.footer">`). Filled at the usage site, so it can
|
|
2412
2579
|
// only change when the tag itself re-renders - which builds a new instance
|
|
2413
2580
|
if (this.slots) Object.defineProperty(raw, SLOTS, { value: this.slots })
|
|
2581
|
+
// in place before the store wraps it, because the scripts that increment it
|
|
2582
|
+
// run against the store and the template reads it back through the same
|
|
2583
|
+
// scope chain. Read back out of `raw` where it is needed rather than kept
|
|
2584
|
+
// in a local: this frame is on the stack for the whole of the subtree it
|
|
2585
|
+
// renders, so a component nested inside itself pays for it once per level -
|
|
2586
|
+
// and the depth guard at MAX_NESTING_DEPTH only beats a RangeError while
|
|
2587
|
+
// this function stays small (see the note in docs/development.md)
|
|
2588
|
+
Object.defineProperty(raw, PENDING_SCRIPTS, { value: { count: 0 } as PendingScripts })
|
|
2414
2589
|
|
|
2415
2590
|
const store = $reactive(raw)
|
|
2416
2591
|
const fx = createEffectScope(store)
|
|
@@ -2453,16 +2628,18 @@ export class Component79 {
|
|
|
2453
2628
|
return !event.defaultPrevented
|
|
2454
2629
|
}
|
|
2455
2630
|
|
|
2456
|
-
// `await $mounted()` suspends a setup script until
|
|
2457
|
-
//
|
|
2458
|
-
// is
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2461
|
-
//
|
|
2631
|
+
// `await $mounted()` suspends a setup script until the component is
|
|
2632
|
+
// rendered *and* attached, so code below it can querySelector its own DOM.
|
|
2633
|
+
// If this instance is never mounted, the promise stays pending and the
|
|
2634
|
+
// script's tail never runs.
|
|
2635
|
+
//
|
|
2636
|
+
// Calling it is also how a script releases the first render - see the gate
|
|
2637
|
+
// below - so the two halves of the contract are one call: "put me on the
|
|
2638
|
+
// page, and don't wait for the rest of me"
|
|
2462
2639
|
let resolveMounted!: () => void
|
|
2463
2640
|
const mounted = new Promise<void>(resolve => { resolveMounted = resolve })
|
|
2464
2641
|
this.resolveMounted = resolveMounted
|
|
2465
|
-
|
|
2642
|
+
this.renderDone = false
|
|
2466
2643
|
|
|
2467
2644
|
// $self / $$self mirror $ / $$ but only search this instance's own
|
|
2468
2645
|
// output: the sibling nodes between its markers. They work detached too
|
|
@@ -2533,7 +2710,31 @@ export class Component79 {
|
|
|
2533
2710
|
// first line, so deferring doesn't shift the lines devtools reports for it
|
|
2534
2711
|
const defer = (code: string) => `await $mounted();${code}`
|
|
2535
2712
|
|
|
2713
|
+
// what the first render is still waiting for. A script holds the template
|
|
2714
|
+
// back until it returns or calls $mounted() - whichever comes first - so
|
|
2715
|
+
// `let rows = await fetch(...)` renders once, with rows, instead of
|
|
2716
|
+
// rendering empty and filling in. `:mounted` is not a special case here: it
|
|
2717
|
+
// *is* a script that yields on line 0, which is what `defer` above writes.
|
|
2718
|
+
//
|
|
2719
|
+
// One gate per script, not one per instance: a script yielding must not
|
|
2720
|
+
// release the render on behalf of a sibling script that is still fetching
|
|
2721
|
+
const gates: Promise<void>[] = []
|
|
2722
|
+
let allSync = true
|
|
2723
|
+
|
|
2536
2724
|
this.scripts.forEach((script, index) => {
|
|
2725
|
+
let resolveGate!: () => void
|
|
2726
|
+
gates.push(new Promise<void>(resolve => { resolveGate = resolve }))
|
|
2727
|
+
// whether this gate is already open on *this* stack, which is not the
|
|
2728
|
+
// same as the script having finished: a script that yields immediately
|
|
2729
|
+
// (`await $mounted()` on its first line, which is what `:mounted`
|
|
2730
|
+
// compiles to) never finishes synchronously but holds nothing up either.
|
|
2731
|
+
// Reading the promise instead would push every such render a microtask
|
|
2732
|
+
// later, for no one's benefit
|
|
2733
|
+
let open = false
|
|
2734
|
+
const release = () => { open = true; resolveGate() }
|
|
2735
|
+
// this script's own view of $mounted: the call releases its gate, the
|
|
2736
|
+
// promise it returns is the instance's (one mount, one resolution)
|
|
2737
|
+
const $mounted = () => { release(); return mounted }
|
|
2537
2738
|
// the file's other components are passed as parameters of the compiled
|
|
2538
2739
|
// script, not just left on the store's prototype: a factory script runs
|
|
2539
2740
|
// as plain lexical JS with no `with`, so a bare `Row` in one would
|
|
@@ -2542,20 +2743,53 @@ export class Component79 {
|
|
|
2542
2743
|
// parameter is what the name resolves to
|
|
2543
2744
|
const instanceHelpers = { $mounted, $self, $$self, ...injected, ...siblingScope }
|
|
2544
2745
|
const at: ScriptLocation = { filename: this.filename, index }
|
|
2746
|
+
const deferred = ":mounted" in script.attrs
|
|
2545
2747
|
const factoryCode = transformFactoryScript(script.content)
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2748
|
+
const run = ((): ScriptRun => {
|
|
2749
|
+
if (factoryCode !== null) {
|
|
2750
|
+
// a factory publishes its names by returning them, so one that yields
|
|
2751
|
+
// before it returns renders against a store where none of them exist.
|
|
2752
|
+
// In factory mode `:mounted` yields on line 0, which means *always* -
|
|
2753
|
+
// and unlike a setup script there is no way to put the useful half
|
|
2754
|
+
// above the yield. Awaiting $mounted() inside the factory does what
|
|
2755
|
+
// the author meant, and is what the message points at
|
|
2756
|
+
if (deferred) {
|
|
2757
|
+
console.warn(
|
|
2758
|
+
"jq79: :mounted on a factory script renders the template before the factory has returned, " +
|
|
2759
|
+
"so none of its bindings exist yet - await $mounted() inside the factory instead."
|
|
2760
|
+
)
|
|
2761
|
+
}
|
|
2762
|
+
declareProps(store, parseFactoryProps(script.content))
|
|
2763
|
+
const body = deferred ? defer(factoryCode) : factoryCode
|
|
2764
|
+
return runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
2765
|
+
}
|
|
2766
|
+
const { vars, code } = transformSetupScript(script.content)
|
|
2767
|
+
declareProps(store, setupSignature(script))
|
|
2768
|
+
// pre-declare script vars on the store so `with` resolves assignments
|
|
2769
|
+
// to them (and reads of them) through the reactive proxy
|
|
2770
|
+
vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
|
|
2771
|
+
const body = deferred ? defer(code) : code
|
|
2772
|
+
return runSetupScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
2773
|
+
})()
|
|
2774
|
+
// a script that threw has nothing left to contribute, so its rejection
|
|
2775
|
+
// releases the gate exactly as completion does - the error is already
|
|
2776
|
+
// reported by the runner, and holding the template hostage to it would
|
|
2777
|
+
// turn one broken script into a blank component. It also stops counting
|
|
2778
|
+
// as a source of names, for that same reason.
|
|
2779
|
+
//
|
|
2780
|
+
// A script that finished on this stack is counted at zero rather than
|
|
2781
|
+
// incremented and decremented a microtask later: its names are on the
|
|
2782
|
+
// store already, and the promise it settles through does not resolve
|
|
2783
|
+
// until after the synchronous paint - which is every paint, for the
|
|
2784
|
+
// components that have no await in them at all
|
|
2785
|
+
if (run.sync) run.settled.then(release, release)
|
|
2786
|
+
else {
|
|
2787
|
+
const pending: PendingScripts = (raw as any)[PENDING_SCRIPTS]
|
|
2788
|
+
pending.count++
|
|
2789
|
+
const settle = () => { pending.count--; release() }
|
|
2790
|
+
run.settled.then(settle, settle)
|
|
2551
2791
|
}
|
|
2552
|
-
|
|
2553
|
-
declareProps(store, setupSignature(script))
|
|
2554
|
-
// pre-declare script vars on the store so `with` resolves assignments
|
|
2555
|
-
// to them (and reads of them) through the reactive proxy
|
|
2556
|
-
vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
|
|
2557
|
-
const body = ":mounted" in script.attrs ? defer(code) : code
|
|
2558
|
-
runSetupScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
2792
|
+
if (!run.sync && !open) allSync = false
|
|
2559
2793
|
})
|
|
2560
2794
|
|
|
2561
2795
|
const content = document.createDocumentFragment()
|
|
@@ -2570,8 +2804,36 @@ export class Component79 {
|
|
|
2570
2804
|
? injected[key]
|
|
2571
2805
|
: Reflect.get(target, key, receiver),
|
|
2572
2806
|
})
|
|
2573
|
-
|
|
2807
|
+
// the markers go in either way, so render() returns something mountable
|
|
2808
|
+
// whether or not the template has been built yet: they are what detach()
|
|
2809
|
+
// collects between and what the deferred pass inserts before, exactly as
|
|
2810
|
+
// :if/:each anchors already work. That is what keeps render() and mount()
|
|
2811
|
+
// synchronous while the first render itself is allowed to wait
|
|
2812
|
+
content.append(this.startMarker, this.endMarker)
|
|
2574
2813
|
this.content = content
|
|
2814
|
+
if (allSync) {
|
|
2815
|
+
// nothing is pending, so the template is built on this stack - the
|
|
2816
|
+
// ordinary case, and byte-for-byte the timing render() has always had.
|
|
2817
|
+
//
|
|
2818
|
+
// Written out rather than routed through the closure below on purpose: a
|
|
2819
|
+
// component that nests itself recurses through here, so one extra frame
|
|
2820
|
+
// per level is one fewer level before the stack gives out - enough, when
|
|
2821
|
+
// this was a shared `paint()`, to overflow *underneath* the depth guard
|
|
2822
|
+
// at MAX_NESTING_DEPTH and turn a named error back into a RangeError
|
|
2823
|
+
this.endMarker.parentNode!.insertBefore(renderNodes(this.template, templateScope, fx, shadow), this.endMarker)
|
|
2824
|
+
this.renderDone = true
|
|
2825
|
+
this.settleMounted()
|
|
2826
|
+
} else {
|
|
2827
|
+
Promise.all(gates).then(() => {
|
|
2828
|
+
// destroy() nulls the markers and a re-render replaces them, so a gate
|
|
2829
|
+
// that opens after either one has nothing left to paint into
|
|
2830
|
+
if (marker !== this.startMarker) return
|
|
2831
|
+
this.endMarker!.parentNode!.insertBefore(renderNodes(this.template, templateScope, fx, shadow), this.endMarker!)
|
|
2832
|
+
this.renderDone = true
|
|
2833
|
+
this.settleMounted()
|
|
2834
|
+
})
|
|
2835
|
+
warnIfStuck(this, gates)
|
|
2836
|
+
}
|
|
2575
2837
|
|
|
2576
2838
|
if (shadow) {
|
|
2577
2839
|
this.styleEls = this.styles.map(style => {
|
|
@@ -2618,10 +2880,18 @@ export class Component79 {
|
|
|
2618
2880
|
if (this.useShadow) this.styleEls.forEach(el => root.appendChild(el))
|
|
2619
2881
|
root.appendChild(this.content!)
|
|
2620
2882
|
this.mountRoot = root
|
|
2621
|
-
this.
|
|
2883
|
+
this.settleMounted()
|
|
2622
2884
|
return this
|
|
2623
2885
|
}
|
|
2624
2886
|
|
|
2887
|
+
// `await $mounted()` means "rendered and on the page", so it waits for both -
|
|
2888
|
+
// whichever lands last calls this. In the ordinary synchronous flow the render
|
|
2889
|
+
// is already done and this is the attach; for a component whose first render
|
|
2890
|
+
// a script held back, it is the other way round
|
|
2891
|
+
private settleMounted() {
|
|
2892
|
+
if (this.renderDone && this.mountRoot) this.resolveMounted?.()
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2625
2895
|
// detaches from the DOM while keeping all state; a later mount() re-attaches
|
|
2626
2896
|
// with any updates that happened while detached already applied
|
|
2627
2897
|
detach(): this {
|
|
@@ -2657,6 +2927,7 @@ export class Component79 {
|
|
|
2657
2927
|
this.content = null
|
|
2658
2928
|
this.startMarker = null
|
|
2659
2929
|
this.endMarker = null
|
|
2930
|
+
this.renderDone = false
|
|
2660
2931
|
this.data = null
|
|
2661
2932
|
this.resolveMounted = null
|
|
2662
2933
|
return this
|