[{"data":1,"prerenderedAt":19015},["ShallowReactive",2],{"writes-list":3},[4,1855,6420,10313,12190,13914,18779],{"id":5,"title":6,"body":7,"date":1832,"description":1833,"description_ar":1834,"draft":1835,"extension":1836,"image":1837,"keywords":1838,"lang":1848,"meta":1849,"navigation":323,"path":1850,"seo":1851,"stem":1852,"title_ar":1853,"updatedAt":1837,"__hash__":1854},"writes\u002Fwrites\u002Finside-a-skeleton-self-generating-skeleton-loaders.md","Inside @alikhalilll\u002Fa-skeleton, self-generating skeleton loaders",{"type":8,"value":9,"toc":1821},"minimark",[10,14,21,24,29,32,38,101,106,137,166,173,187,190,194,201,223,230,241,244,286,289,388,416,422,426,433,478,503,509,512,516,527,943,980,983,1053,1056,1173,1176,1199,1202,1206,1209,1215,1252,1271,1288,1298,1302,1350,1353,1360,1436,1449,1453,1496,1502,1513,1602,1609,1718,1740,1743,1747,1766,1784,1791,1795,1806,1812,1815,1818],[11,12,13],"p",{},"Skeleton loaders are duplicate UI. You build a card, then you build a second card made of grey rectangles that approximates the first, then every time you refactor the real card you forget to refactor the placeholder. Three weeks later the heading is left-aligned in the real component and centred in the skeleton, and nobody notices until a designer scrolls past it on staging.",[11,15,16,20],{},[17,18,19],"code",{},"@alikhalilll\u002Fa-skeleton"," is built around a different premise: the skeleton derives itself from the real component. You wrap your card once, and the library figures out the placeholder by reading the card. No parallel template. No grey rectangles to keep in sync. The refactor you do on the card is the refactor the skeleton picks up on the next render.",[11,22,23],{},"This post is what's underneath that: the authoring contract that makes it work, the three rendering strategies that pay for different trade-offs, and the capture pipeline behind clone mode, which is the part I'd carry into any future \"I need a copy of how this looks right now\" problem.",[25,26,28],"h2",{"id":27},"the-contract-keep-tags-unconditional-gate-content","The contract: keep tags unconditional, gate content",[11,30,31],{},"Before anything else, there's one rule. It's the load-bearing piece for every strategy below, so I'm putting it at the top.",[11,33,34],{},[35,36,37],"strong",{},"Wrong:",[39,40,45],"pre",{"className":41,"code":42,"language":43,"meta":44,"style":44},"language-vue shiki shiki-themes github-light github-dark","\u003Cdiv v-if=\"user\">\n  \u003Ch3>{{ user.name }}\u003C\u002Fh3>\n  \u003Cp>{{ user.bio }}\u003C\u002Fp>\n\u003C\u002Fdiv>\n","vue","",[17,46,47,79,85,91],{"__ignoreMap":44},[48,49,52,56,60,64,67,71,74,76],"span",{"class":50,"line":51},"line",1,[48,53,55],{"class":54},"sVt8B","\u003C",[48,57,59],{"class":58},"s9eBZ","div",[48,61,63],{"class":62},"szBVR"," v-if",[48,65,66],{"class":54},"=",[48,68,70],{"class":69},"sZZnC","\"",[48,72,73],{"class":54},"user",[48,75,70],{"class":69},[48,77,78],{"class":54},">\n",[48,80,82],{"class":50,"line":81},2,[48,83,84],{"class":54},"  \u003Ch3>{{ user.name }}\u003C\u002Fh3>\n",[48,86,88],{"class":50,"line":87},3,[48,89,90],{"class":54},"  \u003Cp>{{ user.bio }}\u003C\u002Fp>\n",[48,92,94,97,99],{"class":50,"line":93},4,[48,95,96],{"class":54},"\u003C\u002F",[48,98,59],{"class":58},[48,100,78],{"class":54},[11,102,103],{},[35,104,105],{},"Right:",[39,107,109],{"className":41,"code":108,"language":43,"meta":44,"style":44},"\u003Cdiv>\n  \u003Ch3>{{ user?.name }}\u003C\u002Fh3>\n  \u003Cp>{{ user?.bio }}\u003C\u002Fp>\n\u003C\u002Fdiv>\n",[17,110,111,119,124,129],{"__ignoreMap":44},[48,112,113,115,117],{"class":50,"line":51},[48,114,55],{"class":54},[48,116,59],{"class":58},[48,118,78],{"class":54},[48,120,121],{"class":50,"line":81},[48,122,123],{"class":54},"  \u003Ch3>{{ user?.name }}\u003C\u002Fh3>\n",[48,125,126],{"class":50,"line":87},[48,127,128],{"class":54},"  \u003Cp>{{ user?.bio }}\u003C\u002Fp>\n",[48,130,131,133,135],{"class":50,"line":93},[48,132,96],{"class":54},[48,134,59],{"class":58},[48,136,78],{"class":54},[11,138,139,140,143,144,147,148,150,151,154,155,158,159,158,162,165],{},"The walker, whether it's reading the live DOM or the vnode tree, needs to see the same shape during loading as it'll see after data arrives. If the heading is wrapped in ",[17,141,142],{},"v-if=\"user\"",", the walker sees a comment node during loading and an ",[17,145,146],{},"\u003Ch3>"," after, and it has no way to predict the placeholder geometry. With the optional-chained interpolation, the ",[17,149,146],{}," is always there; its text is empty during loading, and the walker classifies empty text-owner tags (",[17,152,153],{},"\u003Ch1>","–",[17,156,157],{},"\u003Ch6>",", ",[17,160,161],{},"\u003Cp>",[17,163,164],{},"\u003Cspan>",", etc.) as shimmer text bars at the heading's natural rendered height.",[11,167,168,169,172],{},"Tags are the schema. Content is the payload. You can swap the payload to ",[17,170,171],{},"null"," without the schema noticing. That single discipline is what unlocks the rest.",[11,174,175,176,179,180,182,183,186],{},"The other half of the contract is the ",[17,177,178],{},"data-skeleton-ignore"," escape hatch. If part of your component is decorative chrome (a gradient background, an always-present brand badge, an SVG flourish) you don't want it replaced by a grey block. Mark it ",[17,181,178],{}," and the walker treats it as invisible; the real element renders verbatim through both states. The corollary, ",[17,184,185],{},"data-skeleton-stop",", says \"don't recurse into this; render it as one block.\" Useful for atomic widgets where the inner structure is noise.",[11,188,189],{},"Two rules, two attributes. That's the whole authoring surface you ever have to learn.",[25,191,193],{"id":192},"clone-mode-snapshot-the-final-paint-replay-the-pixels","Clone mode: snapshot the final paint, replay the pixels",[11,195,196,197,200],{},"Clone is the default, and it's the breakthrough. Most skeleton libraries get into trouble because they try to reason about the CSS cascade. They look at class names. They look at inline styles. They make assumptions about border radius from a ",[17,198,199],{},"rounded-*"," prefix. They guess.",[11,202,203,204,207,208,211,212,215,216,219,220,222],{},"Clone mode stops guessing. It mounts the slot off-screen in a ",[17,205,206],{},"visibility: hidden"," capture host, lets the browser do its full layout and style resolution, and then reads the ",[35,209,210],{},"final computed result"," for every element via ",[17,213,214],{},"getComputedStyle()",". Whatever your styling system is (Tailwind utilities, scoped styles with hashed class names, CSS-in-JS at runtime, DaisyUI semantic tokens, inline ",[17,217,218],{},"style"," attributes set by a parent), by the time ",[17,221,214],{}," runs, all of it has collapsed into one canonical answer per property. The engine snapshots that answer and replays it.",[11,224,225,226,229],{},"The captured surface is comprehensive. Per-edge borders (so a card with a different ",[17,227,228],{},"border-bottom"," reads correctly). Per-corner radii (so an asymmetric pill shape doesn't degrade to a generic rounded rect). Background colour and background image. Box-shadow. Opacity. Filter. Transform. Typography. Mix-blend-mode. Each captured node carries its own frozen inline-style payload; the render loop allocates nothing per node, it just reads what's there.",[11,231,232,233,236,237,240],{},"Text is the part that's easy to get wrong, and the part ",[17,234,235],{},"a-skeleton"," gets right. Instead of guessing wrap points (\"assume text wraps every N characters, assume the last line is 70% width\"), the engine uses ",[17,238,239],{},"Range.getClientRects()"," to capture the exact rendered rectangle of every text line: left, top, width, height. A three-line centred heading on desktop becomes a two-line centred heading on mobile, and the captured rects shrink to match. RTL last-line positions, justified text, manual line breaks: all of them replay 1:1 because they were measured, not heuristic'd.",[11,242,243],{},"The wrapper API for all of this is one prop:",[39,245,247],{"className":41,"code":246,"language":43,"meta":44,"style":44},"\u003CASkeleton :loading=\"loading\">\n  \u003CSomeRichComponent \u002F>\n\u003C\u002FASkeleton>\n",[17,248,249,273,278],{"__ignoreMap":44},[48,250,251,253,256,259,263,265,267,269,271],{"class":50,"line":51},[48,252,55],{"class":54},[48,254,255],{"class":58},"ASkeleton",[48,257,258],{"class":54}," :",[48,260,262],{"class":261},"sScJk","loading",[48,264,66],{"class":54},[48,266,70],{"class":69},[48,268,262],{"class":54},[48,270,70],{"class":69},[48,272,78],{"class":54},[48,274,275],{"class":50,"line":81},[48,276,277],{"class":54},"  \u003CSomeRichComponent \u002F>\n",[48,279,280,282,284],{"class":50,"line":87},[48,281,96],{"class":54},[48,283,255],{"class":58},[48,285,78],{"class":54},[11,287,288],{},"If you want to roll your own capture \u002F replay flow, the underlying function is a public export:",[39,290,294],{"className":291,"code":292,"language":293,"meta":44,"style":44},"language-ts shiki shiki-themes github-light github-dark","import { captureSnapshot, type CaptureSnapshot } from '@alikhalilll\u002Fa-skeleton';\n\nconst snap: CaptureSnapshot = captureSnapshot(myRootEl, {\n  maxDepth: 12,\n  maxNodes: 800,\n  minSize: 4,\n});\n","ts",[17,295,296,319,325,349,360,371,382],{"__ignoreMap":44},[48,297,298,301,304,307,310,313,316],{"class":50,"line":51},[48,299,300],{"class":62},"import",[48,302,303],{"class":54}," { captureSnapshot, ",[48,305,306],{"class":62},"type",[48,308,309],{"class":54}," CaptureSnapshot } ",[48,311,312],{"class":62},"from",[48,314,315],{"class":69}," '@alikhalilll\u002Fa-skeleton'",[48,317,318],{"class":54},";\n",[48,320,321],{"class":50,"line":81},[48,322,324],{"emptyLinePlaceholder":323},true,"\n",[48,326,327,330,334,337,340,343,346],{"class":50,"line":87},[48,328,329],{"class":62},"const",[48,331,333],{"class":332},"sj4cs"," snap",[48,335,336],{"class":62},":",[48,338,339],{"class":261}," CaptureSnapshot",[48,341,342],{"class":62}," =",[48,344,345],{"class":261}," captureSnapshot",[48,347,348],{"class":54},"(myRootEl, {\n",[48,350,351,354,357],{"class":50,"line":93},[48,352,353],{"class":54},"  maxDepth: ",[48,355,356],{"class":332},"12",[48,358,359],{"class":54},",\n",[48,361,363,366,369],{"class":50,"line":362},5,[48,364,365],{"class":54},"  maxNodes: ",[48,367,368],{"class":332},"800",[48,370,359],{"class":54},[48,372,374,377,380],{"class":50,"line":373},6,[48,375,376],{"class":54},"  minSize: ",[48,378,379],{"class":332},"4",[48,381,359],{"class":54},[48,383,385],{"class":50,"line":384},7,[48,386,387],{"class":54},"});\n",[11,389,390,158,393,396,397,400,401,404,405,408,409,412,413,415],{},[17,391,392],{},"maxDepth",[17,394,395],{},"maxNodes",", and ",[17,398,399],{},"minSize"," are the budgets. They're the reason a 5,000-row table doesn't lock up the main thread. The walk bails when it hits the cap, sets ",[17,402,403],{},"truncated: true",", and the wrapper logs a one-time ",[17,406,407],{},"console.warn"," per ",[17,410,411],{},"cacheKey"," so you notice during development instead of in production. ",[17,414,399],{}," drops anything smaller than four CSS pixels per axis, which kills off hairlines and spacer dots that would otherwise show up as confetti in the skeleton.",[11,417,418,419,421],{},"The trade-off clone mode pays is that it's client-side only. You need a real DOM, ",[17,420,214],{},", and a browser to read the final paint. On the server, the slot's real markup renders normally; the snapshot + replay only kick in on hydration. If you need a server-rendered placeholder before hydration finishes, that's what mirror mode is for.",[25,423,425],{"id":424},"mirror-mode-walk-the-vnodes-ship-to-the-server","Mirror mode: walk the vnodes, ship to the server",[11,427,428,429,432],{},"Mirror swaps the engine. Instead of mounting the slot and reading the DOM, it walks the vnode tree at render time. No ",[17,430,431],{},"window"," access. Hydration-clean. Server-renderable.",[39,434,436],{"className":41,"code":435,"language":43,"meta":44,"style":44},"\u003CASkeleton mode=\"mirror\" :loading=\"loading\">\n  \u003CSomeRichComponent \u002F>\n\u003C\u002FASkeleton>\n",[17,437,438,466,470],{"__ignoreMap":44},[48,439,440,442,444,447,449,452,454,456,458,460,462,464],{"class":50,"line":51},[48,441,55],{"class":54},[48,443,255],{"class":58},[48,445,446],{"class":261}," mode",[48,448,66],{"class":54},[48,450,451],{"class":69},"\"mirror\"",[48,453,258],{"class":54},[48,455,262],{"class":261},[48,457,66],{"class":54},[48,459,70],{"class":69},[48,461,262],{"class":54},[48,463,70],{"class":69},[48,465,78],{"class":54},[48,467,468],{"class":50,"line":81},[48,469,277],{"class":54},[48,471,472,474,476],{"class":50,"line":87},[48,473,96],{"class":54},[48,475,255],{"class":58},[48,477,78],{"class":54},[11,479,480,481,484,485,488,489,158,492,158,495,498,499,502],{},"The walker preserves every element's tag and class. Text-bearing leaves become ",[17,482,483],{},"\u003Cspan class=\"a-skel-text-content\">"," (transparent text on a skeleton background) with ",[17,486,487],{},"box-decoration-break: clone"," so the shimmer wraps the exact rendered text width per line. Atomic and interactive tags (",[17,490,491],{},"\u003Cimg>",[17,493,494],{},"\u003Cbutton>",[17,496,497],{},"\u003Csvg>",") become ",[17,500,501],{},"\u003Cdiv class=\"a-skel-block\">"," sized from their class-derived dimensions.",[11,504,505,506,508],{},"Mirror's win is SSR. Its trade-off is fidelity. Without ",[17,507,214],{},", the walker can only see what's authored: the static class names and inline styles. If your component's identity comes from a hashed CSS-in-JS rule or a scoped style block, the mirror won't pick it up the way clone does. For most layouts, the static class is enough, and the SSR-safety is worth more than the last 5% of pixel match. For visually intricate components, clone is the right call.",[11,510,511],{},"Both modes share the same animation pipeline, the same cache, the same a11y baseline. Switching between them is a single prop.",[25,513,515],{"id":514},"structural-mode-trees-in-normal-flow-persisted-between-sessions","Structural mode: trees in normal flow, persisted between sessions",[11,517,518,519,522,523,526],{},"There's a third strategy that I reach for when the wrapper isn't my unit of orchestration: when I want a captured shape to survive across instances, persist between sessions, or reflow with its parent on viewport resize instead of being pinned to absolute coordinates. That's ",[17,520,521],{},"useSkeleton()"," + ",[17,524,525],{},"\u003CASkeletonLayer>",".",[39,528,530],{"className":41,"code":529,"language":43,"meta":44,"style":44},"\u003Cscript setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport { useSkeleton } from '@alikhalilll\u002Fa-skeleton';\n\nconst props = defineProps\u003C{ userId: string }>();\nconst user = ref(null);\nconst loading = computed(() => user.value === null);\nconst containerRef = ref\u003CHTMLElement | null>(null);\n\nconst { shape, clear } = useSkeleton({\n  cacheKey: `user-card:${props.userId}`,\n  target: () => (loading.value ? null : containerRef.value),\n  persist: true,\n});\n\nfetchUser(props.userId).then((u) => (user.value = u));\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv ref=\"containerRef\">\n    \u003CASkeletonLayer v-if=\"loading && shape\" :shape=\"shape\" \u002F>\n    \u003CColdStartFallback v-else-if=\"loading\" \u002F>\n    \u003CUserCard v-else :data=\"user\" \u002F>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[17,531,532,552,566,579,583,610,630,659,688,693,720,741,765,776,781,786,817,826,831,841,858,885,903,924,934],{"__ignoreMap":44},[48,533,534,536,539,542,545,547,550],{"class":50,"line":51},[48,535,55],{"class":54},[48,537,538],{"class":58},"script",[48,540,541],{"class":261}," setup",[48,543,544],{"class":261}," lang",[48,546,66],{"class":54},[48,548,549],{"class":69},"\"ts\"",[48,551,78],{"class":54},[48,553,554,556,559,561,564],{"class":50,"line":81},[48,555,300],{"class":62},[48,557,558],{"class":54}," { computed, ref } ",[48,560,312],{"class":62},[48,562,563],{"class":69}," 'vue'",[48,565,318],{"class":54},[48,567,568,570,573,575,577],{"class":50,"line":87},[48,569,300],{"class":62},[48,571,572],{"class":54}," { useSkeleton } ",[48,574,312],{"class":62},[48,576,315],{"class":69},[48,578,318],{"class":54},[48,580,581],{"class":50,"line":93},[48,582,324],{"emptyLinePlaceholder":323},[48,584,585,587,590,592,595,598,602,604,607],{"class":50,"line":362},[48,586,329],{"class":62},[48,588,589],{"class":332}," props",[48,591,342],{"class":62},[48,593,594],{"class":261}," defineProps",[48,596,597],{"class":54},"\u003C{ ",[48,599,601],{"class":600},"s4XuR","userId",[48,603,336],{"class":62},[48,605,606],{"class":332}," string",[48,608,609],{"class":54}," }>();\n",[48,611,612,614,617,619,622,625,627],{"class":50,"line":373},[48,613,329],{"class":62},[48,615,616],{"class":332}," user",[48,618,342],{"class":62},[48,620,621],{"class":261}," ref",[48,623,624],{"class":54},"(",[48,626,171],{"class":332},[48,628,629],{"class":54},");\n",[48,631,632,634,637,639,642,645,648,651,654,657],{"class":50,"line":384},[48,633,329],{"class":62},[48,635,636],{"class":332}," loading",[48,638,342],{"class":62},[48,640,641],{"class":261}," computed",[48,643,644],{"class":54},"(() ",[48,646,647],{"class":62},"=>",[48,649,650],{"class":54}," user.value ",[48,652,653],{"class":62},"===",[48,655,656],{"class":332}," null",[48,658,629],{"class":54},[48,660,662,664,667,669,671,673,676,679,681,684,686],{"class":50,"line":661},8,[48,663,329],{"class":62},[48,665,666],{"class":332}," containerRef",[48,668,342],{"class":62},[48,670,621],{"class":261},[48,672,55],{"class":54},[48,674,675],{"class":261},"HTMLElement",[48,677,678],{"class":62}," |",[48,680,656],{"class":332},[48,682,683],{"class":54},">(",[48,685,171],{"class":332},[48,687,629],{"class":54},[48,689,691],{"class":50,"line":690},9,[48,692,324],{"emptyLinePlaceholder":323},[48,694,696,698,701,704,706,709,712,714,717],{"class":50,"line":695},10,[48,697,329],{"class":62},[48,699,700],{"class":54}," { ",[48,702,703],{"class":332},"shape",[48,705,158],{"class":54},[48,707,708],{"class":332},"clear",[48,710,711],{"class":54}," } ",[48,713,66],{"class":62},[48,715,716],{"class":261}," useSkeleton",[48,718,719],{"class":54},"({\n",[48,721,723,726,729,732,734,736,739],{"class":50,"line":722},11,[48,724,725],{"class":54},"  cacheKey: ",[48,727,728],{"class":69},"`user-card:${",[48,730,731],{"class":54},"props",[48,733,526],{"class":69},[48,735,601],{"class":54},[48,737,738],{"class":69},"}`",[48,740,359],{"class":54},[48,742,744,747,750,752,755,758,760,762],{"class":50,"line":743},12,[48,745,746],{"class":261},"  target",[48,748,749],{"class":54},": () ",[48,751,647],{"class":62},[48,753,754],{"class":54}," (loading.value ",[48,756,757],{"class":62},"?",[48,759,656],{"class":332},[48,761,258],{"class":62},[48,763,764],{"class":54}," containerRef.value),\n",[48,766,768,771,774],{"class":50,"line":767},13,[48,769,770],{"class":54},"  persist: ",[48,772,773],{"class":332},"true",[48,775,359],{"class":54},[48,777,779],{"class":50,"line":778},14,[48,780,387],{"class":54},[48,782,784],{"class":50,"line":783},15,[48,785,324],{"emptyLinePlaceholder":323},[48,787,789,792,795,798,801,804,807,809,812,814],{"class":50,"line":788},16,[48,790,791],{"class":261},"fetchUser",[48,793,794],{"class":54},"(props.userId).",[48,796,797],{"class":261},"then",[48,799,800],{"class":54},"((",[48,802,803],{"class":600},"u",[48,805,806],{"class":54},") ",[48,808,647],{"class":62},[48,810,811],{"class":54}," (user.value ",[48,813,66],{"class":62},[48,815,816],{"class":54}," u));\n",[48,818,820,822,824],{"class":50,"line":819},17,[48,821,96],{"class":54},[48,823,538],{"class":58},[48,825,78],{"class":54},[48,827,829],{"class":50,"line":828},18,[48,830,324],{"emptyLinePlaceholder":323},[48,832,834,836,839],{"class":50,"line":833},19,[48,835,55],{"class":54},[48,837,838],{"class":58},"template",[48,840,78],{"class":54},[48,842,844,847,849,851,853,856],{"class":50,"line":843},20,[48,845,846],{"class":54},"  \u003C",[48,848,59],{"class":58},[48,850,621],{"class":261},[48,852,66],{"class":54},[48,854,855],{"class":69},"\"containerRef\"",[48,857,78],{"class":54},[48,859,861,864,867,869,871,874,877,879,882],{"class":50,"line":860},21,[48,862,863],{"class":54},"    \u003C",[48,865,866],{"class":58},"ASkeletonLayer",[48,868,63],{"class":261},[48,870,66],{"class":54},[48,872,873],{"class":69},"\"loading && shape\"",[48,875,876],{"class":261}," :shape",[48,878,66],{"class":54},[48,880,881],{"class":69},"\"shape\"",[48,883,884],{"class":54}," \u002F>\n",[48,886,888,890,893,896,898,901],{"class":50,"line":887},22,[48,889,863],{"class":54},[48,891,892],{"class":58},"ColdStartFallback",[48,894,895],{"class":261}," v-else-if",[48,897,66],{"class":54},[48,899,900],{"class":69},"\"loading\"",[48,902,884],{"class":54},[48,904,906,908,911,914,917,919,922],{"class":50,"line":905},23,[48,907,863],{"class":54},[48,909,910],{"class":58},"UserCard",[48,912,913],{"class":261}," v-else",[48,915,916],{"class":261}," :data",[48,918,66],{"class":54},[48,920,921],{"class":69},"\"user\"",[48,923,884],{"class":54},[48,925,927,930,932],{"class":50,"line":926},24,[48,928,929],{"class":54},"  \u003C\u002F",[48,931,59],{"class":58},[48,933,78],{"class":54},[48,935,937,939,941],{"class":50,"line":936},25,[48,938,96],{"class":54},[48,940,838],{"class":58},[48,942,78],{"class":54},[11,944,945,946,949,950,158,953,158,956,158,959,158,962,158,965,968,969,971,972,975,976,979],{},"The shape is captured by ",[17,947,948],{},"walkStructural()",", a tree walker that preserves container tags, their original classes, and the resolved layout CSS (",[17,951,952],{},"display",[17,954,955],{},"flex-*",[17,957,958],{},"gap",[17,960,961],{},"padding",[17,963,964],{},"grid-*",[17,966,967],{},"box-sizing","). Leaves carry inline width\u002Fheight plus the visual signals (background, border, radius). When ",[17,970,525],{}," replays the tree, it does so in ",[35,973,974],{},"normal flow",": the captured ",[17,977,978],{},"\u003Cdiv class=\"flex flex-col gap-4 p-4\">"," is rendered as a real flex container, and the shimmer blocks sit inside it as flex children. Resize the viewport and the layout reflows; the skeleton doesn't lag behind the way an absolute-positioned clone replay would.",[11,981,982],{},"Given this real markup:",[39,984,988],{"className":985,"code":986,"language":987,"meta":44,"style":44},"language-html shiki shiki-themes github-light github-dark","\u003Cdiv class=\"flex flex-col gap-4 p-4\">\n  \u003Ch3>...\u003C\u002Fh3>\n  \u003Cp>...\u003C\u002Fp>\n  \u003Cbutton>...\u003C\u002Fbutton>\n\u003C\u002Fdiv>\n","html",[17,989,990,1006,1020,1032,1045],{"__ignoreMap":44},[48,991,992,994,996,999,1001,1004],{"class":50,"line":51},[48,993,55],{"class":54},[48,995,59],{"class":58},[48,997,998],{"class":261}," class",[48,1000,66],{"class":54},[48,1002,1003],{"class":69},"\"flex flex-col gap-4 p-4\"",[48,1005,78],{"class":54},[48,1007,1008,1010,1013,1016,1018],{"class":50,"line":81},[48,1009,846],{"class":54},[48,1011,1012],{"class":58},"h3",[48,1014,1015],{"class":54},">...\u003C\u002F",[48,1017,1012],{"class":58},[48,1019,78],{"class":54},[48,1021,1022,1024,1026,1028,1030],{"class":50,"line":87},[48,1023,846],{"class":54},[48,1025,11],{"class":58},[48,1027,1015],{"class":54},[48,1029,11],{"class":58},[48,1031,78],{"class":54},[48,1033,1034,1036,1039,1041,1043],{"class":50,"line":93},[48,1035,846],{"class":54},[48,1037,1038],{"class":58},"button",[48,1040,1015],{"class":54},[48,1042,1038],{"class":58},[48,1044,78],{"class":54},[48,1046,1047,1049,1051],{"class":50,"line":362},[48,1048,96],{"class":54},[48,1050,59],{"class":58},[48,1052,78],{"class":54},[11,1054,1055],{},"The layer replays as:",[39,1057,1059],{"className":985,"code":1058,"language":987,"meta":44,"style":44},"\u003Cdiv\n  class=\"flex flex-col gap-4 p-4\"\n  style=\"display: flex; flex-direction: column; gap: 16px; padding: 16px; box-sizing: border-box\"\n>\n  \u003Cdiv class=\"a-skel\" style=\"width: 200px; height: 24px; ...\" \u002F>\n  \u003Cdiv class=\"a-skel\" style=\"width: 280px; height: 16px; ...\" \u002F>\n  \u003Cdiv class=\"a-skel\" style=\"width: 120px; height: 36px; ...\" \u002F>\n\u003C\u002Fdiv>\n",[17,1060,1061,1068,1078,1088,1092,1119,1142,1165],{"__ignoreMap":44},[48,1062,1063,1065],{"class":50,"line":51},[48,1064,55],{"class":54},[48,1066,1067],{"class":58},"div\n",[48,1069,1070,1073,1075],{"class":50,"line":81},[48,1071,1072],{"class":261},"  class",[48,1074,66],{"class":54},[48,1076,1077],{"class":69},"\"flex flex-col gap-4 p-4\"\n",[48,1079,1080,1083,1085],{"class":50,"line":87},[48,1081,1082],{"class":261},"  style",[48,1084,66],{"class":54},[48,1086,1087],{"class":69},"\"display: flex; flex-direction: column; gap: 16px; padding: 16px; box-sizing: border-box\"\n",[48,1089,1090],{"class":50,"line":93},[48,1091,78],{"class":54},[48,1093,1094,1096,1098,1100,1102,1105,1108,1110,1113,1117],{"class":50,"line":362},[48,1095,846],{"class":54},[48,1097,59],{"class":58},[48,1099,998],{"class":261},[48,1101,66],{"class":54},[48,1103,1104],{"class":69},"\"a-skel\"",[48,1106,1107],{"class":261}," style",[48,1109,66],{"class":54},[48,1111,1112],{"class":69},"\"width: 200px; height: 24px; ...\"",[48,1114,1116],{"class":1115},"s7hpK"," \u002F",[48,1118,78],{"class":54},[48,1120,1121,1123,1125,1127,1129,1131,1133,1135,1138,1140],{"class":50,"line":373},[48,1122,846],{"class":54},[48,1124,59],{"class":58},[48,1126,998],{"class":261},[48,1128,66],{"class":54},[48,1130,1104],{"class":69},[48,1132,1107],{"class":261},[48,1134,66],{"class":54},[48,1136,1137],{"class":69},"\"width: 280px; height: 16px; ...\"",[48,1139,1116],{"class":1115},[48,1141,78],{"class":54},[48,1143,1144,1146,1148,1150,1152,1154,1156,1158,1161,1163],{"class":50,"line":384},[48,1145,846],{"class":54},[48,1147,59],{"class":58},[48,1149,998],{"class":261},[48,1151,66],{"class":54},[48,1153,1104],{"class":69},[48,1155,1107],{"class":261},[48,1157,66],{"class":54},[48,1159,1160],{"class":69},"\"width: 120px; height: 36px; ...\"",[48,1162,1116],{"class":1115},[48,1164,78],{"class":54},[48,1166,1167,1169,1171],{"class":50,"line":661},[48,1168,96],{"class":54},[48,1170,59],{"class":58},[48,1172,78],{"class":54},[11,1174,1175],{},"The original class is preserved so utility-first CSS still applies; the resolved layout CSS is inlined as a fallback so the skeleton looks correct even when the stylesheet isn't loaded at the mount point. This matters if you're rendering the same captured shape into a different CSS context, like an email-style preview window or a server-rendered shell that doesn't pull Tailwind.",[11,1177,1178,1179,1182,1183,1186,1187,1190,1191,1194,1195,1198],{},"The other thing structural mode gives you is persistence. ",[17,1180,1181],{},"persist: true"," mirrors the captured shape into ",[17,1184,1185],{},"localStorage"," under the ",[17,1188,1189],{},"a-skeleton:s:"," prefix. Cold start on a return visit reads the cached shape and replays the skeleton on first paint: no flash of \"generic shimmer\" while the real component mounts and measures itself. The cache is schema-versioned (",[17,1192,1193],{},"v: 3"," for structural, ",[17,1196,1197],{},"v: 2"," for the legacy flat shape), so mismatched versions auto-purge on read. You can't replay yesterday's geometry through today's renderer if the data shape doesn't match.",[11,1200,1201],{},"The three strategies pick different default behaviours from the same primitives. Clone replays absolutely-positioned pixels. Mirror replays vnodes in the original tree. Structural replays containers in normal flow. One cache module, one theming surface, one a11y baseline, three engines.",[25,1203,1205],{"id":1204},"the-capture-cache-and-replay-pipeline","The capture, cache, and replay pipeline",[11,1207,1208],{},"The architectural story is the same across all three modes, even though the implementations diverge. Here's how a single loading cycle plays out, end to end.",[11,1210,1211,1214],{},[35,1212,1213],{},"First paint, cache miss."," The wrapper mounts. There's no cached shape yet. In clone and mirror mode, the engine walks the slot's structure (vnodes for mirror, the off-screen DOM mount for clone) and produces a placeholder that mirrors the slot's tag structure. Containers preserve their semantic tags and layout CSS. Atomic leaves and text-bearing elements become shimmer placeholders. The user sees a structural skeleton that matches the real component's outline instantly, before any data has resolved.",[11,1216,1217,1220,1221,1224,1225,1228,1229,1232,1233,1235,1236,1238,1239,1242,1243,1245,1246,1248,1249,1251],{},[35,1218,1219],{},"Data arrives."," The slot's real content renders. A ",[17,1222,1223],{},"ResizeObserver"," plus a ",[17,1226,1227],{},"requestAnimationFrame"," schedules a measurement pass. In clone mode, ",[17,1230,1231],{},"captureSnapshot()"," reads ",[17,1234,214],{}," on every element and per-line text rects via ",[17,1237,239],{},", then freezes the result into a ",[17,1240,1241],{},"CaptureSnapshot"," tree. In structural mode, ",[17,1244,948],{}," produces a frozen tree where containers carry their layout CSS and leaves carry their dimensions and visual signals. Either way, the captured snapshot is now in memory (and optionally in ",[17,1247,1185],{}," if ",[17,1250,1181],{},").",[11,1253,1254,1257,1258,1260,1261,1263,1264,1267,1268,1270],{},[35,1255,1256],{},"Next loading flip."," The user navigates back to the page, or triggers a refetch, or revisits the route on a return session. ",[17,1259,262],{}," flips to ",[17,1262,773],{},". The cached shape replays instantly, with no intermediate \"guess the shape\" step. In clone mode, ",[17,1265,1266],{},"\u003CASkeletonClone>"," renders a tree of absolutely-positioned divs each carrying its pre-frozen inline style; the render loop is allocation-free because every style payload was computed at capture time. In structural mode, ",[17,1269,525],{}," renders the tree in normal flow, with the captured layout CSS driving the flex\u002Fgrid container behaviour.",[11,1272,1273,1274,1276,1277,1279,1280,1282,1283,408,1285,1287],{},"The budgets matter at every step. ",[17,1275,395],{}," (default 500–800 depending on the walker) is the cap on tree size. ",[17,1278,392],{}," (default 12–16) is the recursion cap. ",[17,1281,399],{}," (default 4 CSS pixels) is the per-leaf filter. These aren't arbitrary: they're chosen so a busy dashboard with hundreds of leaf elements still walks in a single rAF and the captured tree fits in a localStorage entry without blowing past the 5 MB quota. When a walk truncates, the wrapper logs a one-time ",[17,1284,407],{},[17,1286,411],{}," so you find out in dev rather than in production.",[11,1289,1290,1291,1294,1295,1297],{},"One layout read up front, then cached values for the rest of the walk. The ",[17,1292,1293],{},"getBoundingClientRect()"," and ",[17,1296,214],{}," calls happen in a single top-down pass with no intervening writes, no layout thrash: one forced reflow, then the captured snapshot is durable.",[25,1299,1301],{"id":1300},"the-15-named-primitives","The 15 named primitives",[11,1303,1304,1305,158,1308,158,1311,158,1314,158,1317,158,1320,158,1323,158,1326,158,1329,158,1332,158,1335,158,1338,158,1341,158,1344,158,1347,526],{},"Self-generating doesn't fit every case. If your \"loading state\" doesn't have a real component to wrap (you're showing the skeleton before any data fetch starts, or the placeholder genuinely has different markup from the loaded view), the auto-capture approach has nothing to read. For those cases the package ships 15 named variants: ",[17,1306,1307],{},"\u003CASkeletonCard>",[17,1309,1310],{},"\u003CASkeletonText>",[17,1312,1313],{},"\u003CASkeletonHeading>",[17,1315,1316],{},"\u003CASkeletonAvatar>",[17,1318,1319],{},"\u003CASkeletonImage>",[17,1321,1322],{},"\u003CASkeletonVideo>",[17,1324,1325],{},"\u003CASkeletonButton>",[17,1327,1328],{},"\u003CASkeletonInput>",[17,1330,1331],{},"\u003CASkeletonChip>",[17,1333,1334],{},"\u003CASkeletonListItem>",[17,1336,1337],{},"\u003CASkeletonTable>",[17,1339,1340],{},"\u003CASkeletonChart>",[17,1342,1343],{},"\u003CASkeletonForm>",[17,1345,1346],{},"\u003CASkeletonArticle>",[17,1348,1349],{},"\u003CASkeletonDivider>",[11,1351,1352],{},"They're not a replacement for the auto-capture path; they're an exit ramp. Same animation pipeline, same theming tokens, same a11y baseline. Hand-authored when you need them, otherwise let the wrapper do the work.",[11,1354,1355,1356,1359],{},"There's also ",[17,1357,1358],{},"\u003CASkeletonBlock>"," for the case where none of the variants fit and you want to compose your own:",[39,1361,1363],{"className":41,"code":1362,"language":43,"meta":44,"style":44},"\u003Cdiv v-if=\"loading\" class=\"flex items-start gap-4 p-4\">\n  \u003CASkeletonBlock type=\"circle\" :w=\"64\" :h=\"64\" \u002F>\n  \u003Cdiv class=\"flex-1 space-y-2\">\n    \u003CASkeletonBlock type=\"text\" :w=\"160\" :h=\"18\" \u002F>\n    \u003CASkeletonBlock type=\"text\" :w=\"100\" :h=\"12\" \u002F>\n    \u003CASkeletonBlock type=\"text\" :lines=\"3\" :h=\"14\" class=\"!mt-3\" \u002F>\n  \u003C\u002Fdiv>\n\u003C\u002Fdiv>\n\u003CUserCard v-else :data=\"user\" \u002F>\n",[17,1364,1365,1390,1395,1400,1405,1410,1415,1423,1431],{"__ignoreMap":44},[48,1366,1367,1369,1371,1373,1375,1377,1379,1381,1383,1385,1388],{"class":50,"line":51},[48,1368,55],{"class":54},[48,1370,59],{"class":58},[48,1372,63],{"class":62},[48,1374,66],{"class":54},[48,1376,70],{"class":69},[48,1378,262],{"class":54},[48,1380,70],{"class":69},[48,1382,998],{"class":261},[48,1384,66],{"class":54},[48,1386,1387],{"class":69},"\"flex items-start gap-4 p-4\"",[48,1389,78],{"class":54},[48,1391,1392],{"class":50,"line":81},[48,1393,1394],{"class":54},"  \u003CASkeletonBlock type=\"circle\" :w=\"64\" :h=\"64\" \u002F>\n",[48,1396,1397],{"class":50,"line":87},[48,1398,1399],{"class":54},"  \u003Cdiv class=\"flex-1 space-y-2\">\n",[48,1401,1402],{"class":50,"line":93},[48,1403,1404],{"class":54},"    \u003CASkeletonBlock type=\"text\" :w=\"160\" :h=\"18\" \u002F>\n",[48,1406,1407],{"class":50,"line":362},[48,1408,1409],{"class":54},"    \u003CASkeletonBlock type=\"text\" :w=\"100\" :h=\"12\" \u002F>\n",[48,1411,1412],{"class":50,"line":373},[48,1413,1414],{"class":54},"    \u003CASkeletonBlock type=\"text\" :lines=\"3\" :h=\"14\" class=\"!mt-3\" \u002F>\n",[48,1416,1417,1419,1421],{"class":50,"line":384},[48,1418,929],{"class":54},[48,1420,59],{"class":58},[48,1422,78],{"class":54},[48,1424,1425,1427,1429],{"class":50,"line":661},[48,1426,96],{"class":54},[48,1428,59],{"class":58},[48,1430,78],{"class":54},[48,1432,1433],{"class":50,"line":690},[48,1434,1435],{"class":54},"\u003CUserCard v-else :data=\"user\" \u002F>\n",[11,1437,1438,1440,1441,1444,1445,1448],{},[17,1439,1358],{}," is flow-friendly. It composes with flex, grid, stack layouts. The ",[17,1442,1443],{},"lines"," prop on ",[17,1446,1447],{},"type=\"text\""," stacks N bars with the last at 70% width, which is the heuristic that's correct enough most of the time when you don't have a real component to measure.",[25,1450,1452],{"id":1451},"animations-and-theming-as-strategies","Animations and theming, as strategies",[11,1454,1455,1456,1459,1460,1463,1464,1467,1468,1471,1472,1475,1476,1479,1480,1483,1484,1487,1488,1491,1492,1495],{},"Four animation modes ship: ",[17,1457,1458],{},"shimmer"," (a gradient sweep on a ",[17,1461,1462],{},"::after"," pseudo-element, contained per block with ",[17,1465,1466],{},"overflow: hidden","), ",[17,1469,1470],{},"pulse"," (opacity cycling from 1 to ",[17,1473,1474],{},"--ak-skel-pulse-min"," and back), ",[17,1477,1478],{},"wave"," (a sliding gradient via ",[17,1481,1482],{},"background-position","), and ",[17,1485,1486],{},"none"," (static blocks). Each is a strategy, not a branch. You pass ",[17,1489,1490],{},"animation=\"pulse\""," and the right CSS class engages. No ",[17,1493,1494],{},"if (animation === 'pulse')"," chain in the component.",[11,1497,1498,1501],{},[17,1499,1500],{},"prefers-reduced-motion: reduce"," disables every animation automatically. The shimmer pseudo-element drops to a static low-opacity overlay; the pulse stops cycling. No component code involved; it's a media query in the stylesheet.",[11,1503,1504,1505,1508,1509,1512],{},"Theming is CSS variables. The tokens are scoped under ",[17,1506,1507],{},"--ak-skel-*"," (with backward-compat aliases under ",[17,1510,1511],{},"--ak-skeleton-*"," for v1 consumers):",[1514,1515,1516,1529],"table",{},[1517,1518,1519],"thead",{},[1520,1521,1522,1526],"tr",{},[1523,1524,1525],"th",{},"Token",[1523,1527,1528],{},"Used for",[1530,1531,1532,1543,1553,1563,1573,1582,1592],"tbody",{},[1520,1533,1534,1540],{},[1535,1536,1537],"td",{},[17,1538,1539],{},"--ak-skel-base",[1535,1541,1542],{},"Block fill",[1520,1544,1545,1550],{},[1535,1546,1547],{},[17,1548,1549],{},"--ak-skel-highlight",[1535,1551,1552],{},"Shimmer \u002F wave sweep colour",[1520,1554,1555,1560],{},[1535,1556,1557],{},[17,1558,1559],{},"--ak-skel-radius",[1535,1561,1562],{},"Default block border radius",[1520,1564,1565,1570],{},[1535,1566,1567],{},[17,1568,1569],{},"--ak-skel-duration",[1535,1571,1572],{},"Animation cycle length",[1520,1574,1575,1579],{},[1535,1576,1577],{},[17,1578,1474],{},[1535,1580,1581],{},"Opacity at the trough of the pulse cycle",[1520,1583,1584,1589],{},[1535,1585,1586],{},[17,1587,1588],{},"--ak-skel-ring",[1535,1590,1591],{},"Subtle 1-px inset ring colour",[1520,1593,1594,1599],{},[1535,1595,1596],{},[17,1597,1598],{},"--ak-skel-icon",[1535,1600,1601],{},"Placeholder icon colour (image \u002F video variants)",[11,1603,1604,1605,1608],{},"Override them on ",[17,1606,1607],{},":root",", on a wrapper class for multi-tenant themes, or inline on a single instance:",[39,1610,1614],{"className":1611,"code":1612,"language":1613,"meta":44,"style":44},"language-css shiki shiki-themes github-light github-dark",".tenant-acme {\n  --ak-skel-base: hsl(220 30% 18%);\n  --ak-skel-highlight: hsl(220 60% 60% \u002F 0.35);\n  --ak-skel-radius: 0.5rem;\n  --ak-skel-duration: 2s;\n}\n","css",[17,1615,1616,1624,1653,1683,1698,1713],{"__ignoreMap":44},[48,1617,1618,1621],{"class":50,"line":51},[48,1619,1620],{"class":261},".tenant-acme",[48,1622,1623],{"class":54}," {\n",[48,1625,1626,1629,1632,1635,1637,1640,1643,1646,1649,1651],{"class":50,"line":81},[48,1627,1628],{"class":600},"  --ak-skel-base",[48,1630,1631],{"class":54},": ",[48,1633,1634],{"class":332},"hsl",[48,1636,624],{"class":54},[48,1638,1639],{"class":332},"220",[48,1641,1642],{"class":332}," 30",[48,1644,1645],{"class":62},"%",[48,1647,1648],{"class":332}," 18",[48,1650,1645],{"class":62},[48,1652,629],{"class":54},[48,1654,1655,1658,1660,1662,1664,1666,1669,1671,1673,1675,1678,1681],{"class":50,"line":87},[48,1656,1657],{"class":600},"  --ak-skel-highlight",[48,1659,1631],{"class":54},[48,1661,1634],{"class":332},[48,1663,624],{"class":54},[48,1665,1639],{"class":332},[48,1667,1668],{"class":332}," 60",[48,1670,1645],{"class":62},[48,1672,1668],{"class":332},[48,1674,1645],{"class":62},[48,1676,1677],{"class":54}," \u002F ",[48,1679,1680],{"class":332},"0.35",[48,1682,629],{"class":54},[48,1684,1685,1688,1690,1693,1696],{"class":50,"line":93},[48,1686,1687],{"class":600},"  --ak-skel-radius",[48,1689,1631],{"class":54},[48,1691,1692],{"class":332},"0.5",[48,1694,1695],{"class":62},"rem",[48,1697,318],{"class":54},[48,1699,1700,1703,1705,1708,1711],{"class":50,"line":362},[48,1701,1702],{"class":600},"  --ak-skel-duration",[48,1704,1631],{"class":54},[48,1706,1707],{"class":332},"2",[48,1709,1710],{"class":62},"s",[48,1712,318],{"class":54},[48,1714,1715],{"class":50,"line":373},[48,1716,1717],{"class":54},"}\n",[11,1719,1720,1721,1724,1725,1728,1729,1732,1733,1735,1736,1739],{},"Dark mode is automatic via ",[17,1722,1723],{},":where(.dark)"," selectors that retint the tokens (Tailwind's ",[17,1726,1727],{},".dark"," class, shadcn's, ",[17,1730,1731],{},"nuxt-color-mode","'s, anything that lands a ",[17,1734,1727],{}," ancestor), plus a ",[17,1737,1738],{},"@media (prefers-color-scheme: dark)"," fallback for the case where no explicit class is on the tree.",[11,1741,1742],{},"The point of doing it this way is that no consumer should ever have to override an internal class. Every visual decision is a token; tokens are inheritable through the cascade; per-instance overrides are inline styles. The component owns the structure and the animation; you own the look.",[25,1744,1746],{"id":1745},"a11y-you-dont-have-to-configure","A11y you don't have to configure",[11,1748,1749,1750,1753,1754,1757,1758,1761,1762,1765],{},"The defaults are the ones you'd want anyway. Every wrapper and variant root carries ",[17,1751,1752],{},"role=\"status\""," while loading, ",[17,1755,1756],{},"aria-busy=\"true\""," mirroring the loading state, and ",[17,1759,1760],{},"aria-live=\"polite\""," so screen readers announce the loading state without interrupting the user. A visually-hidden ",[17,1763,1764],{},"\u003Cspan class=\"a-skel-sr-only\">Loading...\u003C\u002Fspan>"," ships with every variant so the announcement has text to read.",[11,1767,1768,1769,1772,1773,1294,1776,1779,1780,1783],{},"Every emitted shimmer surface carries ",[17,1770,1771],{},"aria-hidden=\"true\"",": the placeholder is decorative; the wrapper is what gets announced. Mirror-mode skeletons disable ",[17,1774,1775],{},"user-select",[17,1777,1778],{},"pointer-events"," on the slot tree so the placeholder can't be interacted with mid-load. ",[17,1781,1782],{},"prefers-reduced-motion"," strips animation.",[11,1785,1786,1787,1790],{},"None of this is configurable. None of it should be. The defaults are the right answer. If you need them off, you're either testing something specific (in which case ",[17,1788,1789],{},"animation=\"none\""," is the lever) or doing something a11y-wise that I don't want to make easy.",[25,1792,1794],{"id":1793},"what-clone-mode-taught-me","What clone mode taught me",[11,1796,1797,1798,1801,1802,1805],{},"The thing I'd carry into the next problem isn't the API. It's the insight that powers clone mode. You can stop reasoning about the cascade. You can stop trying to predict what ",[17,1799,1800],{},"border-radius: 0.75rem"," mixed with ",[17,1803,1804],{},"rounded-2xl"," mixed with a scoped style override actually produces. Mount the thing. Let the browser collapse all the inputs. Read the final answer. Replay the final answer.",[11,1807,1808,1809,1811],{},"That principle applies anywhere you have a copy-of-this-element problem. Print previews. Drag previews. Component galleries that render the same widget under five themes. The \"look at what's actually on the screen, not at the rules that produced it\" approach scales further than people give it credit for. Every CSS pipeline, every variable, every cascade priority, every animation frame ends up resolved to one number per property per element, and that number is what ",[17,1810,214],{}," will tell you if you ask.",[11,1813,1814],{},"The other thing that earned its keep is the authoring contract. Tags unconditional, content via interpolation. Once you internalise that, every component you build is automatically skeleton-ready: no extra template, no parallel state, no maintenance overhead. The library reads your real component; you keep writing it the way you already wanted to.",[11,1816,1817],{},"Two ideas, one package. The repo is MIT-licensed and the README has the long-form reference for every prop and slot if you want to dig further. If you spot something that could be sharper, open an issue. I'd genuinely rather be corrected than comfortable.",[218,1819,1820],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .s7hpK, html code.shiki .s7hpK{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic}",{"title":44,"searchDepth":81,"depth":81,"links":1822},[1823,1824,1825,1826,1827,1828,1829,1830,1831],{"id":27,"depth":81,"text":28},{"id":192,"depth":81,"text":193},{"id":424,"depth":81,"text":425},{"id":514,"depth":81,"text":515},{"id":1204,"depth":81,"text":1205},{"id":1300,"depth":81,"text":1301},{"id":1451,"depth":81,"text":1452},{"id":1745,"depth":81,"text":1746},{"id":1793,"depth":81,"text":1794},"2026-06-13","How a-skeleton derives placeholder UI from the real component. Clone mode, structural mode, mirror mode, and the capture, cache, and replay pipeline behind them.","كيف تستخرج a-skeleton واجهة الانتظار من المكوّن نفسه. وضع Clone، ووضع Structural، ووضع Mirror، ومسار العمل الذي يلتقط ثم يُخزّن مؤقّتاً ثم يُعيد العرض خلفها.",false,"md",null,[1839,1840,1841,1842,1843,1844,1223,1845,1846,1847],"Vue 3","Nuxt","skeleton loader","loading states","getComputedStyle","Range API","TypeScript","component library","accessibility","en",{},"\u002Fwrites\u002Finside-a-skeleton-self-generating-skeleton-loaders",{"title":6,"description":1833},"writes\u002Finside-a-skeleton-self-generating-skeleton-loaders","داخل @alikhalilll\u002Fa-skeleton، مُحمِّلات skeleton تولّد نفسها بنفسها","VxGF-EgIL4tFkWPV2BLDCQVNC3i8p1quakbSMMxOdpw",{"id":1856,"title":1857,"body":1858,"date":6403,"description":6404,"description_ar":6405,"draft":1835,"extension":1836,"image":1837,"keywords":6406,"lang":1848,"meta":6414,"navigation":323,"path":6415,"seo":6416,"stem":6417,"title_ar":6418,"updatedAt":6403,"__hash__":6419},"writes\u002Fwrites\u002Fatell-input-the-library-rewrite.md","Notes from rewriting ATellInput as a published library",{"type":8,"value":1859,"toc":6393},[1860,1866,1869,1876,1879,1883,1886,1949,1963,1966,1982,1985,2072,2099,2249,2266,2277,2280,2284,2287,2375,2385,2404,2410,2973,2976,2994,3022,3037,3040,3043,3219,3236,3308,3311,3315,3322,3328,3662,3665,3734,3737,4066,4093,4099,4142,4153,4156,4240,4249,4253,4256,4263,4374,4392,4395,4399,4402,4405,4972,5014,5021,5161,5174,5177,5307,5314,5317,5454,5468,5471,5475,5502,5505,5738,5745,5971,5974,6148,6155,6158,6162,6168,6173,6295,6305,6308,6312,6323,6330,6333,6376,6379,6390],[11,1861,1862,1865],{},[17,1863,1864],{},"ATellInput"," started its life inside one app: a Saudi\u002FEgypt-leaning fallback, an internal RestCountries fetch, validation surfaced via v-model only, a dropdown that was always on screen. That version solved correctness: cursor jumps, validation reasons, bidi. It got most things right for one consumer.",[11,1867,1868],{},"This post is what happened when I tried to publish it.",[11,1870,1871,1872,1875],{},"The component now lives in ",[17,1873,1874],{},"@alikhalilll\u002Fui\u002Ftell-input",", and a published library has a different job description. You can't bake in your own market. You can't assume the consumer's design system. You can't expect every mount point to wait for a third-party API. You don't get to decide whether the user is on a phone or a laptop. The rewrite kept the original's correctness and added what a library consumer actually needs, and almost every change has a specific bug or limitation in the old version that motivated it.",[11,1877,1878],{},"I've grouped the changes around the seven that earned their place.",[25,1880,1882],{"id":1881},"two-models-two-shapes-number-out-iso2-inside","Two models, two shapes: number out, ISO2 inside",[11,1884,1885],{},"The old surface looked clean from a distance:",[39,1887,1891],{"className":1888,"code":1889,"language":1890,"meta":44,"style":44},"language-typescript shiki shiki-themes github-light github-dark","const phoneModelValue = defineModel\u003Cstring>('phone', { required: true });\nconst countryModelValue = defineModel\u003Cstring>('country', { required: true });\n","typescript",[17,1892,1893,1923],{"__ignoreMap":44},[48,1894,1895,1897,1900,1902,1905,1907,1910,1912,1915,1918,1920],{"class":50,"line":51},[48,1896,329],{"class":62},[48,1898,1899],{"class":332}," phoneModelValue",[48,1901,342],{"class":62},[48,1903,1904],{"class":261}," defineModel",[48,1906,55],{"class":54},[48,1908,1909],{"class":332},"string",[48,1911,683],{"class":54},[48,1913,1914],{"class":69},"'phone'",[48,1916,1917],{"class":54},", { required: ",[48,1919,773],{"class":332},[48,1921,1922],{"class":54}," });\n",[48,1924,1925,1927,1930,1932,1934,1936,1938,1940,1943,1945,1947],{"class":50,"line":81},[48,1926,329],{"class":62},[48,1928,1929],{"class":332}," countryModelValue",[48,1931,342],{"class":62},[48,1933,1904],{"class":261},[48,1935,55],{"class":54},[48,1937,1909],{"class":332},[48,1939,683],{"class":54},[48,1941,1942],{"class":69},"'country'",[48,1944,1917],{"class":54},[48,1946,773],{"class":332},[48,1948,1922],{"class":54},[11,1950,1951,1954,1955,1958,1959,1962],{},[17,1952,1953],{},"country"," was a string of dial digits: ",[17,1956,1957],{},"\"20\""," for Egypt, ",[17,1960,1961],{},"\"44\""," for the UK. Easy to serialize, easy to URL-encode, easy to put in a database column.",[11,1964,1965],{},"It also couldn't tell US from Canada.",[11,1967,1968,1971,1972,1975,1976,1978,1979,1981],{},[17,1969,1970],{},"+1"," is shared by 25+ NANP countries. If a Canadian user picked Canada in the dropdown and the v-model emitted ",[17,1973,1974],{},"\"1\"",", the parent had no way to know which ",[17,1977,1970],{}," was meant. Reload the page, hydrate from the URL, and you'd get back ",[17,1980,1974],{}," with the picker showing whichever country the dropdown happened to list first. The string-of-dial-digits model was a lossy serialization of a two-piece thing.",[11,1983,1984],{},"The new model splits it:",[39,1986,1988],{"className":1888,"code":1987,"language":1890,"meta":44,"style":44},"const phone = defineModel\u003Cstring>('phone', { default: '' });\nconst country = defineModel\u003Cnumber | null>('country', { default: null });\n\nconst selectedIso2 = ref\u003Cstring>('');\n",[17,1989,1990,2017,2047,2051],{"__ignoreMap":44},[48,1991,1992,1994,1997,1999,2001,2003,2005,2007,2009,2012,2015],{"class":50,"line":51},[48,1993,329],{"class":62},[48,1995,1996],{"class":332}," phone",[48,1998,342],{"class":62},[48,2000,1904],{"class":261},[48,2002,55],{"class":54},[48,2004,1909],{"class":332},[48,2006,683],{"class":54},[48,2008,1914],{"class":69},[48,2010,2011],{"class":54},", { default: ",[48,2013,2014],{"class":69},"''",[48,2016,1922],{"class":54},[48,2018,2019,2021,2024,2026,2028,2030,2033,2035,2037,2039,2041,2043,2045],{"class":50,"line":81},[48,2020,329],{"class":62},[48,2022,2023],{"class":332}," country",[48,2025,342],{"class":62},[48,2027,1904],{"class":261},[48,2029,55],{"class":54},[48,2031,2032],{"class":332},"number",[48,2034,678],{"class":62},[48,2036,656],{"class":332},[48,2038,683],{"class":54},[48,2040,1942],{"class":69},[48,2042,2011],{"class":54},[48,2044,171],{"class":332},[48,2046,1922],{"class":54},[48,2048,2049],{"class":50,"line":87},[48,2050,324],{"emptyLinePlaceholder":323},[48,2052,2053,2055,2058,2060,2062,2064,2066,2068,2070],{"class":50,"line":93},[48,2054,329],{"class":62},[48,2056,2057],{"class":332}," selectedIso2",[48,2059,342],{"class":62},[48,2061,621],{"class":261},[48,2063,55],{"class":54},[48,2065,1909],{"class":332},[48,2067,683],{"class":54},[48,2069,2014],{"class":69},[48,2071,629],{"class":54},[11,2073,2074,2076,2077,158,2080,158,2083,2085,2086,2090,2091,2094,2095,2098],{},[17,2075,1953],{}," is a number now (",[17,2078,2079],{},"20",[17,2081,2082],{},"1",[17,2084,171],{},") because dial codes ",[2087,2088,2089],"em",{},"are"," numbers, and ",[17,2092,2093],{},"Number"," survives URL\u002FDB\u002FJSON round-trips with no string-trimming weirdness. But the component also tracks an internal ",[17,2096,2097],{},"selectedIso2"," that the picker owns. The two stay in sync via a pair of watchers, one outward, one inward, and the inward one has a small but important guard:",[39,2100,2102],{"className":1888,"code":2101,"language":1890,"meta":44,"style":44},"watch(\n  country,\n  (next) => {\n    if (next == null) {\n      if (selectedIso2.value) selectedIso2.value = '';\n      return;\n    }\n    if (dialNumberFor(selectedIso2.value) === next) return;\n    const iso2 = resolveCountryIdentifier(String(next));\n    if (iso2) selectedIso2.value = iso2;\n  },\n  { immediate: true }\n);\n",[17,2103,2104,2112,2117,2131,2147,2162,2169,2174,2197,2218,2230,2235,2245],{"__ignoreMap":44},[48,2105,2106,2109],{"class":50,"line":51},[48,2107,2108],{"class":261},"watch",[48,2110,2111],{"class":54},"(\n",[48,2113,2114],{"class":50,"line":81},[48,2115,2116],{"class":54},"  country,\n",[48,2118,2119,2122,2125,2127,2129],{"class":50,"line":87},[48,2120,2121],{"class":54},"  (",[48,2123,2124],{"class":600},"next",[48,2126,806],{"class":54},[48,2128,647],{"class":62},[48,2130,1623],{"class":54},[48,2132,2133,2136,2139,2142,2144],{"class":50,"line":93},[48,2134,2135],{"class":62},"    if",[48,2137,2138],{"class":54}," (next ",[48,2140,2141],{"class":62},"==",[48,2143,656],{"class":332},[48,2145,2146],{"class":54},") {\n",[48,2148,2149,2152,2155,2157,2160],{"class":50,"line":362},[48,2150,2151],{"class":62},"      if",[48,2153,2154],{"class":54}," (selectedIso2.value) selectedIso2.value ",[48,2156,66],{"class":62},[48,2158,2159],{"class":69}," ''",[48,2161,318],{"class":54},[48,2163,2164,2167],{"class":50,"line":373},[48,2165,2166],{"class":62},"      return",[48,2168,318],{"class":54},[48,2170,2171],{"class":50,"line":384},[48,2172,2173],{"class":54},"    }\n",[48,2175,2176,2178,2181,2184,2187,2189,2192,2195],{"class":50,"line":661},[48,2177,2135],{"class":62},[48,2179,2180],{"class":54}," (",[48,2182,2183],{"class":261},"dialNumberFor",[48,2185,2186],{"class":54},"(selectedIso2.value) ",[48,2188,653],{"class":62},[48,2190,2191],{"class":54}," next) ",[48,2193,2194],{"class":62},"return",[48,2196,318],{"class":54},[48,2198,2199,2202,2205,2207,2210,2212,2215],{"class":50,"line":690},[48,2200,2201],{"class":62},"    const",[48,2203,2204],{"class":332}," iso2",[48,2206,342],{"class":62},[48,2208,2209],{"class":261}," resolveCountryIdentifier",[48,2211,624],{"class":54},[48,2213,2214],{"class":261},"String",[48,2216,2217],{"class":54},"(next));\n",[48,2219,2220,2222,2225,2227],{"class":50,"line":695},[48,2221,2135],{"class":62},[48,2223,2224],{"class":54}," (iso2) selectedIso2.value ",[48,2226,66],{"class":62},[48,2228,2229],{"class":54}," iso2;\n",[48,2231,2232],{"class":50,"line":722},[48,2233,2234],{"class":54},"  },\n",[48,2236,2237,2240,2242],{"class":50,"line":743},[48,2238,2239],{"class":54},"  { immediate: ",[48,2241,773],{"class":332},[48,2243,2244],{"class":54}," }\n",[48,2246,2247],{"class":50,"line":767},[48,2248,629],{"class":54},[11,2250,2251,2252,2255,2256,2259,2260,2262,2263,2265],{},"The ",[17,2253,2254],{},"if (dialNumberFor(selectedIso2.value) === next) return;"," line is the one I want to call out. When the parent writes ",[17,2257,2258],{},"country = 1"," back to the model and the picker already has Canada selected, the watch fires, but the dial code already matches, so the watch returns without touching ",[17,2261,2097],{},".\nWithout that guard, the watcher would resolve ",[17,2264,1974],{}," to its alphabetically-first NANP country and silently flip Canada to whatever that is. The guard preserves the richer state.",[11,2267,2268,2269,2272,2273,2276],{},"The outward watch is ",[17,2270,2271],{},"flush: 'sync'"," so a separate ",[17,2274,2275],{},"autoSettingCountry"," flag stays consistent. When the matcher auto-picks a country from typed input, we don't want it counted as a \"manual pick\" that would lock out further auto-detection.",[11,2278,2279],{},"Two halves of the country, two distinct models, one source of truth for each.",[25,2281,2283],{"id":2282},"detection-first-the-picker-is-hidden-by-default","Detection-first: the picker is hidden by default",[11,2285,2286],{},"The old component always rendered the dropdown: flag on the left, input on the right. The new component defaults to the input alone. The dropdown slides in only when the user types something the matcher recognises:",[39,2288,2290],{"className":41,"code":2289,"language":43,"meta":44,"style":44},"\u003CTransition\n  enter-active-class=\"transition-all duration-200 ease-out overflow-hidden\"\n  leave-active-class=\"transition-all duration-150 ease-in overflow-hidden\"\n  enter-from-class=\"opacity-0 -translate-x-1 max-w-0\"\n  leave-to-class=\"opacity-0 -translate-x-1 max-w-0\"\n  enter-to-class=\"max-w-[12rem]\"\n  leave-from-class=\"max-w-[12rem]\"\n>\n  \u003CACountrySelect v-if=\"!props.detectFromInput || selectedIso2\" ... \u002F>\n\u003C\u002FTransition>\n",[17,2291,2292,2299,2309,2319,2329,2338,2348,2357,2361,2366],{"__ignoreMap":44},[48,2293,2294,2296],{"class":50,"line":51},[48,2295,55],{"class":54},[48,2297,2298],{"class":58},"Transition\n",[48,2300,2301,2304,2306],{"class":50,"line":81},[48,2302,2303],{"class":261},"  enter-active-class",[48,2305,66],{"class":54},[48,2307,2308],{"class":69},"\"transition-all duration-200 ease-out overflow-hidden\"\n",[48,2310,2311,2314,2316],{"class":50,"line":87},[48,2312,2313],{"class":261},"  leave-active-class",[48,2315,66],{"class":54},[48,2317,2318],{"class":69},"\"transition-all duration-150 ease-in overflow-hidden\"\n",[48,2320,2321,2324,2326],{"class":50,"line":93},[48,2322,2323],{"class":261},"  enter-from-class",[48,2325,66],{"class":54},[48,2327,2328],{"class":69},"\"opacity-0 -translate-x-1 max-w-0\"\n",[48,2330,2331,2334,2336],{"class":50,"line":362},[48,2332,2333],{"class":261},"  leave-to-class",[48,2335,66],{"class":54},[48,2337,2328],{"class":69},[48,2339,2340,2343,2345],{"class":50,"line":373},[48,2341,2342],{"class":261},"  enter-to-class",[48,2344,66],{"class":54},[48,2346,2347],{"class":69},"\"max-w-[12rem]\"\n",[48,2349,2350,2353,2355],{"class":50,"line":384},[48,2351,2352],{"class":261},"  leave-from-class",[48,2354,66],{"class":54},[48,2356,2347],{"class":69},[48,2358,2359],{"class":50,"line":661},[48,2360,78],{"class":54},[48,2362,2363],{"class":50,"line":690},[48,2364,2365],{"class":54},"  \u003CACountrySelect v-if=\"!props.detectFromInput || selectedIso2\" ... \u002F>\n",[48,2367,2368,2370,2373],{"class":50,"line":695},[48,2369,96],{"class":54},[48,2371,2372],{"class":58},"Transition",[48,2374,78],{"class":54},[11,2376,2377,2380,2381,2384],{},[17,2378,2379],{},"max-w"," collapses to zero when nothing is selected, animates open on the first successful match. ",[17,2382,2383],{},"detectFromInput=\"false\""," brings the always-visible picker back for callers who want the legacy shape.",[11,2386,2387,2388,2391,2392,2395,2396,2399,2400,2403],{},"The behaviour is opinionated. Most users in most countries ",[2087,2389,2390],{},"never"," type a ",[17,2393,2394],{},"+"," prefix. They type the number they would type into their own phone: ",[17,2397,2398],{},"01066105963"," in Cairo, ",[17,2401,2402],{},"07911 123456"," in Manchester. The dropdown they \"have to interact with\" in every other phone input is, for those users, a step they don't need. Hide it. Detect what they're doing. Show it only when the detection lands.",[11,2405,2406,2407,336],{},"The \"detect what they're doing\" part is the only non-trivial piece. It runs through three tiers in ",[17,2408,2409],{},"matchLeadingDialCode",[39,2411,2413],{"className":1888,"code":2412,"language":1890,"meta":44,"style":44},"function matchLeadingDialCode(digits: string): DialMatch | null {\n  if (!digits) return null;\n\n  \u002F\u002F Tier 1: international parse, disambiguates NANP and strips the calling code.\n  try {\n    const parsed = parsePhoneNumberFromString(`+${digits}`);\n    if (parsed?.country && parsed.countryCallingCode) {\n      const parsedCountry = getCountryByValue(parsed.country);\n      if (parsedCountry) {\n        return { country: parsedCountry, nationalNumber: String(parsed.nationalNumber ?? '') };\n      }\n    }\n  } catch {\n    \u002F* libphonenumber throws on partial input, fall through *\u002F\n  }\n\n  \u002F\u002F Tier 2: national-format parse using the silently-inferred country as a hint.\n  const hint = inferredCountry.value;\n  if (hint && digits.length >= 4) {\n    try {\n      const parsed = parsePhoneNumberFromString(digits, hint as CountryCode);\n      if (parsed?.isValid()) {\n        const matched = getCountryByValue(parsed.country || hint);\n        if (matched) {\n          return { country: matched, nationalNumber: String(parsed.nationalNumber ?? '') };\n        }\n      }\n    } catch {\n      \u002F* fall through *\u002F\n    }\n  }\n\n  \u002F\u002F Tier 3: longest-prefix match over our own dial-digits index.\n  for (let len = Math.min(3, digits.length); len >= 1; len--) {\n    const prefix = digits.slice(0, len);\n    const group = getCountriesByDial(prefix);\n    if (!group.length) continue;\n    const nationalNumber = digits.slice(prefix.length);\n    if (group.length === 1) return { country: group[0], nationalNumber };\n    \u002F\u002F ambiguity: prefer current selection, then recents, then first\n    \u002F\u002F (see \"Recents as the tiebreaker\" below)\n    return { country: group[0], nationalNumber };\n  }\n  return null;\n}\n",[17,2414,2415,2446,2465,2469,2475,2482,2505,2518,2534,2541,2562,2567,2571,2581,2586,2591,2595,2600,2613,2636,2643,2664,2677,2698,2706,2724,2730,2735,2745,2751,2756,2761,2766,2772,2821,2844,2860,2881,2902,2929,2935,2941,2953,2958,2968],{"__ignoreMap":44},[48,2416,2417,2420,2423,2425,2428,2430,2432,2435,2437,2440,2442,2444],{"class":50,"line":51},[48,2418,2419],{"class":62},"function",[48,2421,2422],{"class":261}," matchLeadingDialCode",[48,2424,624],{"class":54},[48,2426,2427],{"class":600},"digits",[48,2429,336],{"class":62},[48,2431,606],{"class":332},[48,2433,2434],{"class":54},")",[48,2436,336],{"class":62},[48,2438,2439],{"class":261}," DialMatch",[48,2441,678],{"class":62},[48,2443,656],{"class":332},[48,2445,1623],{"class":54},[48,2447,2448,2451,2453,2456,2459,2461,2463],{"class":50,"line":81},[48,2449,2450],{"class":62},"  if",[48,2452,2180],{"class":54},[48,2454,2455],{"class":62},"!",[48,2457,2458],{"class":54},"digits) ",[48,2460,2194],{"class":62},[48,2462,656],{"class":332},[48,2464,318],{"class":54},[48,2466,2467],{"class":50,"line":87},[48,2468,324],{"emptyLinePlaceholder":323},[48,2470,2471],{"class":50,"line":93},[48,2472,2474],{"class":2473},"sJ8bj","  \u002F\u002F Tier 1: international parse, disambiguates NANP and strips the calling code.\n",[48,2476,2477,2480],{"class":50,"line":362},[48,2478,2479],{"class":62},"  try",[48,2481,1623],{"class":54},[48,2483,2484,2486,2489,2491,2494,2496,2499,2501,2503],{"class":50,"line":373},[48,2485,2201],{"class":62},[48,2487,2488],{"class":332}," parsed",[48,2490,342],{"class":62},[48,2492,2493],{"class":261}," parsePhoneNumberFromString",[48,2495,624],{"class":54},[48,2497,2498],{"class":69},"`+${",[48,2500,2427],{"class":54},[48,2502,738],{"class":69},[48,2504,629],{"class":54},[48,2506,2507,2509,2512,2515],{"class":50,"line":384},[48,2508,2135],{"class":62},[48,2510,2511],{"class":54}," (parsed?.country ",[48,2513,2514],{"class":62},"&&",[48,2516,2517],{"class":54}," parsed.countryCallingCode) {\n",[48,2519,2520,2523,2526,2528,2531],{"class":50,"line":661},[48,2521,2522],{"class":62},"      const",[48,2524,2525],{"class":332}," parsedCountry",[48,2527,342],{"class":62},[48,2529,2530],{"class":261}," getCountryByValue",[48,2532,2533],{"class":54},"(parsed.country);\n",[48,2535,2536,2538],{"class":50,"line":690},[48,2537,2151],{"class":62},[48,2539,2540],{"class":54}," (parsedCountry) {\n",[48,2542,2543,2546,2549,2551,2554,2557,2559],{"class":50,"line":695},[48,2544,2545],{"class":62},"        return",[48,2547,2548],{"class":54}," { country: parsedCountry, nationalNumber: ",[48,2550,2214],{"class":261},[48,2552,2553],{"class":54},"(parsed.nationalNumber ",[48,2555,2556],{"class":62},"??",[48,2558,2159],{"class":69},[48,2560,2561],{"class":54},") };\n",[48,2563,2564],{"class":50,"line":722},[48,2565,2566],{"class":54},"      }\n",[48,2568,2569],{"class":50,"line":743},[48,2570,2173],{"class":54},[48,2572,2573,2576,2579],{"class":50,"line":767},[48,2574,2575],{"class":54},"  } ",[48,2577,2578],{"class":62},"catch",[48,2580,1623],{"class":54},[48,2582,2583],{"class":50,"line":778},[48,2584,2585],{"class":2473},"    \u002F* libphonenumber throws on partial input, fall through *\u002F\n",[48,2587,2588],{"class":50,"line":783},[48,2589,2590],{"class":54},"  }\n",[48,2592,2593],{"class":50,"line":788},[48,2594,324],{"emptyLinePlaceholder":323},[48,2596,2597],{"class":50,"line":819},[48,2598,2599],{"class":2473},"  \u002F\u002F Tier 2: national-format parse using the silently-inferred country as a hint.\n",[48,2601,2602,2605,2608,2610],{"class":50,"line":828},[48,2603,2604],{"class":62},"  const",[48,2606,2607],{"class":332}," hint",[48,2609,342],{"class":62},[48,2611,2612],{"class":54}," inferredCountry.value;\n",[48,2614,2615,2617,2620,2622,2625,2628,2631,2634],{"class":50,"line":833},[48,2616,2450],{"class":62},[48,2618,2619],{"class":54}," (hint ",[48,2621,2514],{"class":62},[48,2623,2624],{"class":54}," digits.",[48,2626,2627],{"class":332},"length",[48,2629,2630],{"class":62}," >=",[48,2632,2633],{"class":332}," 4",[48,2635,2146],{"class":54},[48,2637,2638,2641],{"class":50,"line":843},[48,2639,2640],{"class":62},"    try",[48,2642,1623],{"class":54},[48,2644,2645,2647,2649,2651,2653,2656,2659,2662],{"class":50,"line":860},[48,2646,2522],{"class":62},[48,2648,2488],{"class":332},[48,2650,342],{"class":62},[48,2652,2493],{"class":261},[48,2654,2655],{"class":54},"(digits, hint ",[48,2657,2658],{"class":62},"as",[48,2660,2661],{"class":261}," CountryCode",[48,2663,629],{"class":54},[48,2665,2666,2668,2671,2674],{"class":50,"line":887},[48,2667,2151],{"class":62},[48,2669,2670],{"class":54}," (parsed?.",[48,2672,2673],{"class":261},"isValid",[48,2675,2676],{"class":54},"()) {\n",[48,2678,2679,2682,2685,2687,2689,2692,2695],{"class":50,"line":905},[48,2680,2681],{"class":62},"        const",[48,2683,2684],{"class":332}," matched",[48,2686,342],{"class":62},[48,2688,2530],{"class":261},[48,2690,2691],{"class":54},"(parsed.country ",[48,2693,2694],{"class":62},"||",[48,2696,2697],{"class":54}," hint);\n",[48,2699,2700,2703],{"class":50,"line":926},[48,2701,2702],{"class":62},"        if",[48,2704,2705],{"class":54}," (matched) {\n",[48,2707,2708,2711,2714,2716,2718,2720,2722],{"class":50,"line":936},[48,2709,2710],{"class":62},"          return",[48,2712,2713],{"class":54}," { country: matched, nationalNumber: ",[48,2715,2214],{"class":261},[48,2717,2553],{"class":54},[48,2719,2556],{"class":62},[48,2721,2159],{"class":69},[48,2723,2561],{"class":54},[48,2725,2727],{"class":50,"line":2726},26,[48,2728,2729],{"class":54},"        }\n",[48,2731,2733],{"class":50,"line":2732},27,[48,2734,2566],{"class":54},[48,2736,2738,2741,2743],{"class":50,"line":2737},28,[48,2739,2740],{"class":54},"    } ",[48,2742,2578],{"class":62},[48,2744,1623],{"class":54},[48,2746,2748],{"class":50,"line":2747},29,[48,2749,2750],{"class":2473},"      \u002F* fall through *\u002F\n",[48,2752,2754],{"class":50,"line":2753},30,[48,2755,2173],{"class":54},[48,2757,2759],{"class":50,"line":2758},31,[48,2760,2590],{"class":54},[48,2762,2764],{"class":50,"line":2763},32,[48,2765,324],{"emptyLinePlaceholder":323},[48,2767,2769],{"class":50,"line":2768},33,[48,2770,2771],{"class":2473},"  \u002F\u002F Tier 3: longest-prefix match over our own dial-digits index.\n",[48,2773,2775,2778,2780,2783,2786,2788,2791,2794,2796,2799,2802,2804,2807,2810,2813,2816,2819],{"class":50,"line":2774},34,[48,2776,2777],{"class":62},"  for",[48,2779,2180],{"class":54},[48,2781,2782],{"class":62},"let",[48,2784,2785],{"class":54}," len ",[48,2787,66],{"class":62},[48,2789,2790],{"class":54}," Math.",[48,2792,2793],{"class":261},"min",[48,2795,624],{"class":54},[48,2797,2798],{"class":332},"3",[48,2800,2801],{"class":54},", digits.",[48,2803,2627],{"class":332},[48,2805,2806],{"class":54},"); len ",[48,2808,2809],{"class":62},">=",[48,2811,2812],{"class":332}," 1",[48,2814,2815],{"class":54},"; len",[48,2817,2818],{"class":62},"--",[48,2820,2146],{"class":54},[48,2822,2824,2826,2829,2831,2833,2836,2838,2841],{"class":50,"line":2823},35,[48,2825,2201],{"class":62},[48,2827,2828],{"class":332}," prefix",[48,2830,342],{"class":62},[48,2832,2624],{"class":54},[48,2834,2835],{"class":261},"slice",[48,2837,624],{"class":54},[48,2839,2840],{"class":332},"0",[48,2842,2843],{"class":54},", len);\n",[48,2845,2847,2849,2852,2854,2857],{"class":50,"line":2846},36,[48,2848,2201],{"class":62},[48,2850,2851],{"class":332}," group",[48,2853,342],{"class":62},[48,2855,2856],{"class":261}," getCountriesByDial",[48,2858,2859],{"class":54},"(prefix);\n",[48,2861,2863,2865,2867,2869,2872,2874,2876,2879],{"class":50,"line":2862},37,[48,2864,2135],{"class":62},[48,2866,2180],{"class":54},[48,2868,2455],{"class":62},[48,2870,2871],{"class":54},"group.",[48,2873,2627],{"class":332},[48,2875,806],{"class":54},[48,2877,2878],{"class":62},"continue",[48,2880,318],{"class":54},[48,2882,2884,2886,2889,2891,2893,2895,2898,2900],{"class":50,"line":2883},38,[48,2885,2201],{"class":62},[48,2887,2888],{"class":332}," nationalNumber",[48,2890,342],{"class":62},[48,2892,2624],{"class":54},[48,2894,2835],{"class":261},[48,2896,2897],{"class":54},"(prefix.",[48,2899,2627],{"class":332},[48,2901,629],{"class":54},[48,2903,2905,2907,2910,2912,2915,2917,2919,2921,2924,2926],{"class":50,"line":2904},39,[48,2906,2135],{"class":62},[48,2908,2909],{"class":54}," (group.",[48,2911,2627],{"class":332},[48,2913,2914],{"class":62}," ===",[48,2916,2812],{"class":332},[48,2918,806],{"class":54},[48,2920,2194],{"class":62},[48,2922,2923],{"class":54}," { country: group[",[48,2925,2840],{"class":332},[48,2927,2928],{"class":54},"], nationalNumber };\n",[48,2930,2932],{"class":50,"line":2931},40,[48,2933,2934],{"class":2473},"    \u002F\u002F ambiguity: prefer current selection, then recents, then first\n",[48,2936,2938],{"class":50,"line":2937},41,[48,2939,2940],{"class":2473},"    \u002F\u002F (see \"Recents as the tiebreaker\" below)\n",[48,2942,2944,2947,2949,2951],{"class":50,"line":2943},42,[48,2945,2946],{"class":62},"    return",[48,2948,2923],{"class":54},[48,2950,2840],{"class":332},[48,2952,2928],{"class":54},[48,2954,2956],{"class":50,"line":2955},43,[48,2957,2590],{"class":54},[48,2959,2961,2964,2966],{"class":50,"line":2960},44,[48,2962,2963],{"class":62},"  return",[48,2965,656],{"class":332},[48,2967,318],{"class":54},[48,2969,2971],{"class":50,"line":2970},45,[48,2972,1717],{"class":54},[11,2974,2975],{},"The tiers exist because each handles a class of input the other tiers can't.",[11,2977,2978,2979,2981,2982,2985,2986,2989,2990,2993],{},"Tier 1 takes whatever the user typed, prepends ",[17,2980,2394],{},", and asks libphonenumber to parse it as an international number. If the user typed ",[17,2983,2984],{},"447911123456",", this matches the UK. If they typed ",[17,2987,2988],{},"1416...",", it matches Canada specifically (not generic NANP), because libphonenumber has area-code rules baked in. The catch block exists because libphonenumber throws on partial input. ",[17,2991,2992],{},"447"," isn't a complete number; parsing it raises, and we fall through.",[11,2995,2996,2997,2999,3000,3003,3004,3007,3008,3010,3011,3013,3014,3017,3018,3021],{},"Tier 2 is the path for users who type the number they actually use locally. ",[17,2998,2398],{}," doesn't start with a dial code, so Tier 1 can't help. But if we ",[2087,3001,3002],{},"silently"," know the user is probably in Egypt (from IP or timezone; more on that next), we can pass ",[17,3005,3006],{},"\"EG\""," as a parsing hint and libphonenumber will recognise ",[17,3009,2398],{}," as a valid Egyptian mobile number, strip the leading ",[17,3012,2840],{}," (Egypt's national prefix), and hand back the canonical national significant number ",[17,3015,3016],{},"1066105963",". The ",[17,3019,3020],{},"digits.length >= 4"," guard prevents the parser from treating two-digit area-code starts as full numbers.",[11,3023,3024,3025,3028,3029,3032,3033,3036],{},"Tier 3 is the fallback for \"you typed ",[17,3026,3027],{},"44"," and nothing else.\" Neither libphonenumber pass can match; the input is too short. But our own dial-digits index can: ",[17,3030,3031],{},"byDialDigits.get('44')"," returns the UK group, length 1, done. The bucketed-",[17,3034,3035],{},"Map\u003Cstring, CountryOption[]>"," shape from the old version earns its keep here.",[11,3038,3039],{},"Three tiers feels like a lot. It's the minimum: each tier handles inputs the other two reject.",[11,3041,3042],{},"The handler that calls into it stays small:",[39,3044,3046],{"className":1888,"code":3045,"language":1890,"meta":44,"style":44},"const detectAndApply = useDebounceFn(\n  () => {\n    if (!props.detectFromInput) return;\n    if (userPickedCountry.value || selectedIso2.value) return;\n    const current = phone.value;\n    if (!current) return;\n    const match = matchLeadingDialCode(current);\n    if (!match) return;\n    autoSettingCountry.value = true;\n    selectedIso2.value = match.country.value;\n    phone.value = match.nationalNumber;\n  },\n  computed(() => Math.max(0, props.detectDebounceMs))\n);\n",[17,3047,3048,3062,3071,3086,3102,3114,3129,3143,3158,3170,3180,3190,3194,3215],{"__ignoreMap":44},[48,3049,3050,3052,3055,3057,3060],{"class":50,"line":51},[48,3051,329],{"class":62},[48,3053,3054],{"class":332}," detectAndApply",[48,3056,342],{"class":62},[48,3058,3059],{"class":261}," useDebounceFn",[48,3061,2111],{"class":54},[48,3063,3064,3067,3069],{"class":50,"line":81},[48,3065,3066],{"class":54},"  () ",[48,3068,647],{"class":62},[48,3070,1623],{"class":54},[48,3072,3073,3075,3077,3079,3082,3084],{"class":50,"line":87},[48,3074,2135],{"class":62},[48,3076,2180],{"class":54},[48,3078,2455],{"class":62},[48,3080,3081],{"class":54},"props.detectFromInput) ",[48,3083,2194],{"class":62},[48,3085,318],{"class":54},[48,3087,3088,3090,3093,3095,3098,3100],{"class":50,"line":93},[48,3089,2135],{"class":62},[48,3091,3092],{"class":54}," (userPickedCountry.value ",[48,3094,2694],{"class":62},[48,3096,3097],{"class":54}," selectedIso2.value) ",[48,3099,2194],{"class":62},[48,3101,318],{"class":54},[48,3103,3104,3106,3109,3111],{"class":50,"line":362},[48,3105,2201],{"class":62},[48,3107,3108],{"class":332}," current",[48,3110,342],{"class":62},[48,3112,3113],{"class":54}," phone.value;\n",[48,3115,3116,3118,3120,3122,3125,3127],{"class":50,"line":373},[48,3117,2135],{"class":62},[48,3119,2180],{"class":54},[48,3121,2455],{"class":62},[48,3123,3124],{"class":54},"current) ",[48,3126,2194],{"class":62},[48,3128,318],{"class":54},[48,3130,3131,3133,3136,3138,3140],{"class":50,"line":384},[48,3132,2201],{"class":62},[48,3134,3135],{"class":332}," match",[48,3137,342],{"class":62},[48,3139,2422],{"class":261},[48,3141,3142],{"class":54},"(current);\n",[48,3144,3145,3147,3149,3151,3154,3156],{"class":50,"line":661},[48,3146,2135],{"class":62},[48,3148,2180],{"class":54},[48,3150,2455],{"class":62},[48,3152,3153],{"class":54},"match) ",[48,3155,2194],{"class":62},[48,3157,318],{"class":54},[48,3159,3160,3163,3165,3168],{"class":50,"line":690},[48,3161,3162],{"class":54},"    autoSettingCountry.value ",[48,3164,66],{"class":62},[48,3166,3167],{"class":332}," true",[48,3169,318],{"class":54},[48,3171,3172,3175,3177],{"class":50,"line":695},[48,3173,3174],{"class":54},"    selectedIso2.value ",[48,3176,66],{"class":62},[48,3178,3179],{"class":54}," match.country.value;\n",[48,3181,3182,3185,3187],{"class":50,"line":722},[48,3183,3184],{"class":54},"    phone.value ",[48,3186,66],{"class":62},[48,3188,3189],{"class":54}," match.nationalNumber;\n",[48,3191,3192],{"class":50,"line":743},[48,3193,2234],{"class":54},[48,3195,3196,3199,3201,3203,3205,3208,3210,3212],{"class":50,"line":767},[48,3197,3198],{"class":261},"  computed",[48,3200,644],{"class":54},[48,3202,647],{"class":62},[48,3204,2790],{"class":54},[48,3206,3207],{"class":261},"max",[48,3209,624],{"class":54},[48,3211,2840],{"class":332},[48,3213,3214],{"class":54},", props.detectDebounceMs))\n",[48,3216,3217],{"class":50,"line":778},[48,3218,629],{"class":54},[11,3220,3221,1232,3224,3227,3228,3231,3232,3235],{},[17,3222,3223],{},"useDebounceFn",[17,3225,3226],{},"phone.value"," at the time the timer fires, not at schedule time, so a burst of keystrokes collapses into one parse. ",[17,3229,3230],{},"userPickedCountry.value"," stops the auto-detector from clobbering a manual pick. Once the user has used the dropdown, the matcher steps aside. And clearing the input is ",[2087,3233,3234],{},"not"," debounced; it reverts the picker to hidden immediately:",[39,3237,3239],{"className":1888,"code":3238,"language":1890,"meta":44,"style":44},"if (!cleaned) {\n  autoSettingCountry.value = true;\n  selectedIso2.value = '';\n  phone.value = '';\n  userPickedCountry.value = false;\n  return;\n}\n",[17,3240,3241,3253,3264,3275,3286,3298,3304],{"__ignoreMap":44},[48,3242,3243,3246,3248,3250],{"class":50,"line":51},[48,3244,3245],{"class":62},"if",[48,3247,2180],{"class":54},[48,3249,2455],{"class":62},[48,3251,3252],{"class":54},"cleaned) {\n",[48,3254,3255,3258,3260,3262],{"class":50,"line":81},[48,3256,3257],{"class":54},"  autoSettingCountry.value ",[48,3259,66],{"class":62},[48,3261,3167],{"class":332},[48,3263,318],{"class":54},[48,3265,3266,3269,3271,3273],{"class":50,"line":87},[48,3267,3268],{"class":54},"  selectedIso2.value ",[48,3270,66],{"class":62},[48,3272,2159],{"class":69},[48,3274,318],{"class":54},[48,3276,3277,3280,3282,3284],{"class":50,"line":93},[48,3278,3279],{"class":54},"  phone.value ",[48,3281,66],{"class":62},[48,3283,2159],{"class":69},[48,3285,318],{"class":54},[48,3287,3288,3291,3293,3296],{"class":50,"line":362},[48,3289,3290],{"class":54},"  userPickedCountry.value ",[48,3292,66],{"class":62},[48,3294,3295],{"class":332}," false",[48,3297,318],{"class":54},[48,3299,3300,3302],{"class":50,"line":373},[48,3301,2963],{"class":62},[48,3303,318],{"class":54},[48,3305,3306],{"class":50,"line":384},[48,3307,1717],{"class":54},[11,3309,3310],{},"Debouncing the clear would mean the dropdown lingers for 150ms after the input is empty, which looks wrong every time.",[25,3312,3314],{"id":3313},"the-environment-detection-chain-and-why-its-pluggable","The environment-detection chain (and why it's pluggable)",[11,3316,3317,3318,3321],{},"Tier 2 above depends on ",[17,3319,3320],{},"inferredCountry.value",": the component's best guess at where the user is, set silently before they type anything. The old version's \"best guess\" was a hardcoded fallback to Saudi Arabia or Egypt, because those were the markets the in-app version targeted. A library can't make that assumption.",[11,3323,3324,3325,336],{},"The new chain is ",[17,3326,3327],{},"detectCountry()",[39,3329,3331],{"className":1888,"code":3330,"language":1890,"meta":44,"style":44},"export async function detectCountry(opts: DetectCountryOptions = {}): Promise\u003Cstring> {\n  const {\n    strategy = 'auto',\n    ipEndpoint = 'https:\u002F\u002Fipapi.co\u002Fjson\u002F',\n    defaultCountry = 'US',\n    timeoutMs = 2000,\n    cache = true,\n  } = opts;\n\n  if (cache) {\n    const cached = readCache();\n    if (cached) return cached;\n  }\n\n  if (strategy === 'none') {\n    return defaultCountry.toUpperCase();\n  }\n\n  if (strategy === 'auto') {\n    const ipResult = await tryIp(ipEndpoint, timeoutMs);\n    if (ipResult) {\n      if (cache) writeCache(ipResult);\n      return ipResult;\n    }\n  }\n\n  const localResult = tryTimezone() ?? tryLocale();\n  const final = (localResult ?? defaultCountry).toUpperCase();\n  if (cache) writeCache(final);\n  return final;\n}\n",[17,3332,3333,3374,3380,3392,3404,3416,3428,3439,3448,3452,3459,3474,3486,3490,3494,3508,3520,3524,3528,3540,3558,3565,3578,3585,3589,3593,3597,3619,3640,3651,3658],{"__ignoreMap":44},[48,3334,3335,3338,3341,3344,3347,3349,3352,3354,3357,3359,3362,3364,3367,3369,3371],{"class":50,"line":51},[48,3336,3337],{"class":62},"export",[48,3339,3340],{"class":62}," async",[48,3342,3343],{"class":62}," function",[48,3345,3346],{"class":261}," detectCountry",[48,3348,624],{"class":54},[48,3350,3351],{"class":600},"opts",[48,3353,336],{"class":62},[48,3355,3356],{"class":261}," DetectCountryOptions",[48,3358,342],{"class":62},[48,3360,3361],{"class":54}," {})",[48,3363,336],{"class":62},[48,3365,3366],{"class":261}," Promise",[48,3368,55],{"class":54},[48,3370,1909],{"class":332},[48,3372,3373],{"class":54},"> {\n",[48,3375,3376,3378],{"class":50,"line":81},[48,3377,2604],{"class":62},[48,3379,1623],{"class":54},[48,3381,3382,3385,3387,3390],{"class":50,"line":87},[48,3383,3384],{"class":332},"    strategy",[48,3386,342],{"class":62},[48,3388,3389],{"class":69}," 'auto'",[48,3391,359],{"class":54},[48,3393,3394,3397,3399,3402],{"class":50,"line":93},[48,3395,3396],{"class":332},"    ipEndpoint",[48,3398,342],{"class":62},[48,3400,3401],{"class":69}," 'https:\u002F\u002Fipapi.co\u002Fjson\u002F'",[48,3403,359],{"class":54},[48,3405,3406,3409,3411,3414],{"class":50,"line":362},[48,3407,3408],{"class":332},"    defaultCountry",[48,3410,342],{"class":62},[48,3412,3413],{"class":69}," 'US'",[48,3415,359],{"class":54},[48,3417,3418,3421,3423,3426],{"class":50,"line":373},[48,3419,3420],{"class":332},"    timeoutMs",[48,3422,342],{"class":62},[48,3424,3425],{"class":332}," 2000",[48,3427,359],{"class":54},[48,3429,3430,3433,3435,3437],{"class":50,"line":384},[48,3431,3432],{"class":332},"    cache",[48,3434,342],{"class":62},[48,3436,3167],{"class":332},[48,3438,359],{"class":54},[48,3440,3441,3443,3445],{"class":50,"line":661},[48,3442,2575],{"class":54},[48,3444,66],{"class":62},[48,3446,3447],{"class":54}," opts;\n",[48,3449,3450],{"class":50,"line":690},[48,3451,324],{"emptyLinePlaceholder":323},[48,3453,3454,3456],{"class":50,"line":695},[48,3455,2450],{"class":62},[48,3457,3458],{"class":54}," (cache) {\n",[48,3460,3461,3463,3466,3468,3471],{"class":50,"line":722},[48,3462,2201],{"class":62},[48,3464,3465],{"class":332}," cached",[48,3467,342],{"class":62},[48,3469,3470],{"class":261}," readCache",[48,3472,3473],{"class":54},"();\n",[48,3475,3476,3478,3481,3483],{"class":50,"line":743},[48,3477,2135],{"class":62},[48,3479,3480],{"class":54}," (cached) ",[48,3482,2194],{"class":62},[48,3484,3485],{"class":54}," cached;\n",[48,3487,3488],{"class":50,"line":767},[48,3489,2590],{"class":54},[48,3491,3492],{"class":50,"line":778},[48,3493,324],{"emptyLinePlaceholder":323},[48,3495,3496,3498,3501,3503,3506],{"class":50,"line":783},[48,3497,2450],{"class":62},[48,3499,3500],{"class":54}," (strategy ",[48,3502,653],{"class":62},[48,3504,3505],{"class":69}," 'none'",[48,3507,2146],{"class":54},[48,3509,3510,3512,3515,3518],{"class":50,"line":788},[48,3511,2946],{"class":62},[48,3513,3514],{"class":54}," defaultCountry.",[48,3516,3517],{"class":261},"toUpperCase",[48,3519,3473],{"class":54},[48,3521,3522],{"class":50,"line":819},[48,3523,2590],{"class":54},[48,3525,3526],{"class":50,"line":828},[48,3527,324],{"emptyLinePlaceholder":323},[48,3529,3530,3532,3534,3536,3538],{"class":50,"line":833},[48,3531,2450],{"class":62},[48,3533,3500],{"class":54},[48,3535,653],{"class":62},[48,3537,3389],{"class":69},[48,3539,2146],{"class":54},[48,3541,3542,3544,3547,3549,3552,3555],{"class":50,"line":843},[48,3543,2201],{"class":62},[48,3545,3546],{"class":332}," ipResult",[48,3548,342],{"class":62},[48,3550,3551],{"class":62}," await",[48,3553,3554],{"class":261}," tryIp",[48,3556,3557],{"class":54},"(ipEndpoint, timeoutMs);\n",[48,3559,3560,3562],{"class":50,"line":860},[48,3561,2135],{"class":62},[48,3563,3564],{"class":54}," (ipResult) {\n",[48,3566,3567,3569,3572,3575],{"class":50,"line":887},[48,3568,2151],{"class":62},[48,3570,3571],{"class":54}," (cache) ",[48,3573,3574],{"class":261},"writeCache",[48,3576,3577],{"class":54},"(ipResult);\n",[48,3579,3580,3582],{"class":50,"line":905},[48,3581,2166],{"class":62},[48,3583,3584],{"class":54}," ipResult;\n",[48,3586,3587],{"class":50,"line":926},[48,3588,2173],{"class":54},[48,3590,3591],{"class":50,"line":936},[48,3592,2590],{"class":54},[48,3594,3595],{"class":50,"line":2726},[48,3596,324],{"emptyLinePlaceholder":323},[48,3598,3599,3601,3604,3606,3609,3612,3614,3617],{"class":50,"line":2732},[48,3600,2604],{"class":62},[48,3602,3603],{"class":332}," localResult",[48,3605,342],{"class":62},[48,3607,3608],{"class":261}," tryTimezone",[48,3610,3611],{"class":54},"() ",[48,3613,2556],{"class":62},[48,3615,3616],{"class":261}," tryLocale",[48,3618,3473],{"class":54},[48,3620,3621,3623,3626,3628,3631,3633,3636,3638],{"class":50,"line":2737},[48,3622,2604],{"class":62},[48,3624,3625],{"class":332}," final",[48,3627,342],{"class":62},[48,3629,3630],{"class":54}," (localResult ",[48,3632,2556],{"class":62},[48,3634,3635],{"class":54}," defaultCountry).",[48,3637,3517],{"class":261},[48,3639,3473],{"class":54},[48,3641,3642,3644,3646,3648],{"class":50,"line":2747},[48,3643,2450],{"class":62},[48,3645,3571],{"class":54},[48,3647,3574],{"class":261},[48,3649,3650],{"class":54},"(final);\n",[48,3652,3653,3655],{"class":50,"line":2753},[48,3654,2963],{"class":62},[48,3656,3657],{"class":54}," final;\n",[48,3659,3660],{"class":50,"line":2758},[48,3661,1717],{"class":54},[11,3663,3664],{},"Four signals, evaluated in cost order: cheap-and-cached first, expensive-and-networked last.",[3666,3667,3668,3675,3686,3713,3727],"ol",{},[3669,3670,3671,3674],"li",{},[17,3672,3673],{},"sessionStorage"," cache. The user already resolved to a country during this session; we don't redo the work.",[3669,3676,3677,3678,3681,3682,3685],{},"IP geolocation via ",[17,3679,3680],{},"ipapi.co"," (configurable). The most accurate signal but it costs a network call and a tiny bit of privacy. ",[17,3683,3684],{},"strategy: 'locale'"," skips this step for consumers who don't want the request.",[3669,3687,3688,3689,3692,3693,3696,3697,158,3700,3696,3703,158,3706,3696,3709,3712],{},"Timezone, via ",[17,3690,3691],{},"Intl.DateTimeFormat().resolvedOptions().timeZone",", looked up against a hand-rolled table that maps ",[17,3694,3695],{},"Africa\u002FCairo"," to ",[17,3698,3699],{},"EG",[17,3701,3702],{},"Asia\u002FRiyadh",[17,3704,3705],{},"SA",[17,3707,3708],{},"Europe\u002FLondon",[17,3710,3711],{},"GB",", and so on. The map covers the most-populated zones; it's honest about not covering every one. A miss falls through.",[3669,3714,3715,3718,3719,3722,3723,3726],{},[17,3716,3717],{},"navigator.language",", which gives ",[17,3720,3721],{},"en-EG"," or ",[17,3724,3725],{},"ar-SA"," and similar. The region suffix is the country. Less reliable (users move; browser locales don't), but free.",[3669,3728,3729,3730,3733],{},"The caller-supplied default. ",[17,3731,3732],{},"defaultCountry: 'US'"," is the library default; consumers can pick their own.",[11,3735,3736],{},"The IP step is the one that needed real care, because the call can hang:",[39,3738,3740],{"className":1888,"code":3739,"language":1890,"meta":44,"style":44},"async function tryIp(endpoint: string, timeoutMs: number): Promise\u003Cstring | null> {\n  if (!isBrowser() || typeof fetch !== 'function') return null;\n  const controller = new AbortController();\n  const timer = setTimeout(() => controller.abort(), timeoutMs);\n  try {\n    const res = await fetch(endpoint, { signal: controller.signal, credentials: 'omit' });\n    if (!res.ok) return null;\n    const data = (await res.json()) as { country_code?: string; country?: string };\n    const code = (data.country_code ?? data.country ?? '').toString().toUpperCase();\n    return \u002F^[A-Z]{2}$\u002F.test(code) ? code : null;\n  } catch {\n    return null;\n  } finally {\n    clearTimeout(timer);\n  }\n}\n",[17,3741,3742,3786,3821,3838,3863,3869,3891,3908,3955,3988,4025,4033,4041,4050,4058,4062],{"__ignoreMap":44},[48,3743,3744,3747,3749,3751,3753,3756,3758,3760,3762,3765,3767,3770,3772,3774,3776,3778,3780,3782,3784],{"class":50,"line":51},[48,3745,3746],{"class":62},"async",[48,3748,3343],{"class":62},[48,3750,3554],{"class":261},[48,3752,624],{"class":54},[48,3754,3755],{"class":600},"endpoint",[48,3757,336],{"class":62},[48,3759,606],{"class":332},[48,3761,158],{"class":54},[48,3763,3764],{"class":600},"timeoutMs",[48,3766,336],{"class":62},[48,3768,3769],{"class":332}," number",[48,3771,2434],{"class":54},[48,3773,336],{"class":62},[48,3775,3366],{"class":261},[48,3777,55],{"class":54},[48,3779,1909],{"class":332},[48,3781,678],{"class":62},[48,3783,656],{"class":332},[48,3785,3373],{"class":54},[48,3787,3788,3790,3792,3794,3797,3799,3801,3804,3807,3810,3813,3815,3817,3819],{"class":50,"line":81},[48,3789,2450],{"class":62},[48,3791,2180],{"class":54},[48,3793,2455],{"class":62},[48,3795,3796],{"class":261},"isBrowser",[48,3798,3611],{"class":54},[48,3800,2694],{"class":62},[48,3802,3803],{"class":62}," typeof",[48,3805,3806],{"class":54}," fetch ",[48,3808,3809],{"class":62},"!==",[48,3811,3812],{"class":69}," 'function'",[48,3814,806],{"class":54},[48,3816,2194],{"class":62},[48,3818,656],{"class":332},[48,3820,318],{"class":54},[48,3822,3823,3825,3828,3830,3833,3836],{"class":50,"line":87},[48,3824,2604],{"class":62},[48,3826,3827],{"class":332}," controller",[48,3829,342],{"class":62},[48,3831,3832],{"class":62}," new",[48,3834,3835],{"class":261}," AbortController",[48,3837,3473],{"class":54},[48,3839,3840,3842,3845,3847,3850,3852,3854,3857,3860],{"class":50,"line":93},[48,3841,2604],{"class":62},[48,3843,3844],{"class":332}," timer",[48,3846,342],{"class":62},[48,3848,3849],{"class":261}," setTimeout",[48,3851,644],{"class":54},[48,3853,647],{"class":62},[48,3855,3856],{"class":54}," controller.",[48,3858,3859],{"class":261},"abort",[48,3861,3862],{"class":54},"(), timeoutMs);\n",[48,3864,3865,3867],{"class":50,"line":362},[48,3866,2479],{"class":62},[48,3868,1623],{"class":54},[48,3870,3871,3873,3876,3878,3880,3883,3886,3889],{"class":50,"line":373},[48,3872,2201],{"class":62},[48,3874,3875],{"class":332}," res",[48,3877,342],{"class":62},[48,3879,3551],{"class":62},[48,3881,3882],{"class":261}," fetch",[48,3884,3885],{"class":54},"(endpoint, { signal: controller.signal, credentials: ",[48,3887,3888],{"class":69},"'omit'",[48,3890,1922],{"class":54},[48,3892,3893,3895,3897,3899,3902,3904,3906],{"class":50,"line":384},[48,3894,2135],{"class":62},[48,3896,2180],{"class":54},[48,3898,2455],{"class":62},[48,3900,3901],{"class":54},"res.ok) ",[48,3903,2194],{"class":62},[48,3905,656],{"class":332},[48,3907,318],{"class":54},[48,3909,3910,3912,3915,3917,3919,3922,3925,3928,3931,3933,3935,3938,3941,3943,3946,3948,3950,3952],{"class":50,"line":661},[48,3911,2201],{"class":62},[48,3913,3914],{"class":332}," data",[48,3916,342],{"class":62},[48,3918,2180],{"class":54},[48,3920,3921],{"class":62},"await",[48,3923,3924],{"class":54}," res.",[48,3926,3927],{"class":261},"json",[48,3929,3930],{"class":54},"()) ",[48,3932,2658],{"class":62},[48,3934,700],{"class":54},[48,3936,3937],{"class":600},"country_code",[48,3939,3940],{"class":62},"?:",[48,3942,606],{"class":332},[48,3944,3945],{"class":54},"; ",[48,3947,1953],{"class":600},[48,3949,3940],{"class":62},[48,3951,606],{"class":332},[48,3953,3954],{"class":54}," };\n",[48,3956,3957,3959,3962,3964,3967,3969,3972,3974,3976,3978,3981,3984,3986],{"class":50,"line":690},[48,3958,2201],{"class":62},[48,3960,3961],{"class":332}," code",[48,3963,342],{"class":62},[48,3965,3966],{"class":54}," (data.country_code ",[48,3968,2556],{"class":62},[48,3970,3971],{"class":54}," data.country ",[48,3973,2556],{"class":62},[48,3975,2159],{"class":69},[48,3977,1251],{"class":54},[48,3979,3980],{"class":261},"toString",[48,3982,3983],{"class":54},"().",[48,3985,3517],{"class":261},[48,3987,3473],{"class":54},[48,3989,3990,3992,3994,3997,4000,4003,4006,4008,4011,4014,4016,4019,4021,4023],{"class":50,"line":695},[48,3991,2946],{"class":62},[48,3993,1116],{"class":69},[48,3995,3996],{"class":62},"^",[48,3998,3999],{"class":332},"[A-Z]",[48,4001,4002],{"class":62},"{2}$",[48,4004,4005],{"class":69},"\u002F",[48,4007,526],{"class":54},[48,4009,4010],{"class":261},"test",[48,4012,4013],{"class":54},"(code) ",[48,4015,757],{"class":62},[48,4017,4018],{"class":54}," code ",[48,4020,336],{"class":62},[48,4022,656],{"class":332},[48,4024,318],{"class":54},[48,4026,4027,4029,4031],{"class":50,"line":722},[48,4028,2575],{"class":54},[48,4030,2578],{"class":62},[48,4032,1623],{"class":54},[48,4034,4035,4037,4039],{"class":50,"line":743},[48,4036,2946],{"class":62},[48,4038,656],{"class":332},[48,4040,318],{"class":54},[48,4042,4043,4045,4048],{"class":50,"line":767},[48,4044,2575],{"class":54},[48,4046,4047],{"class":62},"finally",[48,4049,1623],{"class":54},[48,4051,4052,4055],{"class":50,"line":778},[48,4053,4054],{"class":261},"    clearTimeout",[48,4056,4057],{"class":54},"(timer);\n",[48,4059,4060],{"class":50,"line":783},[48,4061,2590],{"class":54},[48,4063,4064],{"class":50,"line":788},[48,4065,1717],{"class":54},[11,4067,4068,4071,4072,4074,4075,4078,4079,4082,4083,4086,4087,4086,4090,4092],{},[17,4069,4070],{},"AbortController"," plus a 2-second timer means a slow IP service can't block the detection chain for longer than ",[17,4073,3764],{},". ",[17,4076,4077],{},"credentials: 'omit'"," keeps cookies out of the request to whatever third party you've configured. The shape of the response is checked with ",[17,4080,4081],{},"\u002F^[A-Z]{2}$\u002F"," because some IP APIs return ",[17,4084,4085],{},"\"GB\"",", some return ",[17,4088,4089],{},"\"United Kingdom\"",[17,4091,171],{}," when they don't know. The regex is the gate.",[11,4094,4095,4098],{},[17,4096,4097],{},"detectCountry"," lives outside the component on purpose:",[39,4100,4102],{"className":1888,"code":4101,"language":1890,"meta":44,"style":44},"import { detectCountry } from '@alikhalilll\u002Fui\u002Ftell-input';\n\nconst country = await detectCountry({ strategy: 'locale' });\n",[17,4103,4104,4118,4122],{"__ignoreMap":44},[48,4105,4106,4108,4111,4113,4116],{"class":50,"line":51},[48,4107,300],{"class":62},[48,4109,4110],{"class":54}," { detectCountry } ",[48,4112,312],{"class":62},[48,4114,4115],{"class":69}," '@alikhalilll\u002Fui\u002Ftell-input'",[48,4117,318],{"class":54},[48,4119,4120],{"class":50,"line":81},[48,4121,324],{"emptyLinePlaceholder":323},[48,4123,4124,4126,4128,4130,4132,4134,4137,4140],{"class":50,"line":87},[48,4125,329],{"class":62},[48,4127,2023],{"class":332},[48,4129,342],{"class":62},[48,4131,3551],{"class":62},[48,4133,3346],{"class":261},[48,4135,4136],{"class":54},"({ strategy: ",[48,4138,4139],{"class":69},"'locale'",[48,4141,1922],{"class":54},[11,4143,4144,4145,4148,4149,4152],{},"No Vue dependency, no reactive refs, no ",[17,4146,4147],{},"onMounted",". It works in a server middleware that wants to set a cookie, in a CLI that wants to print where the user probably is, in a unit test that wants to control the strategy. The reactive wrapper (",[17,4150,4151],{},"useCountryDetection",") is a thin shell around it for the in-component case. Two surfaces, one implementation.",[11,4154,4155],{},"And the whole chain is pluggable. If you already detect geo server-side and pass it down through SSR, you don't want the component re-running an IP fetch:",[39,4157,4159],{"className":41,"code":4158,"language":43,"meta":44,"style":44},"\u003CATellInput\n  v-model:phone=\"phone\"\n  v-model:country=\"country\"\n  :detector=\"(opts) => Promise.resolve(serverDetectedCountry.value)\"\n\u002F>\n",[17,4160,4161,4168,4187,4203,4235],{"__ignoreMap":44},[48,4162,4163,4165],{"class":50,"line":51},[48,4164,55],{"class":54},[48,4166,4167],{"class":58},"ATellInput\n",[48,4169,4170,4173,4175,4178,4180,4182,4184],{"class":50,"line":81},[48,4171,4172],{"class":261},"  v-model",[48,4174,336],{"class":54},[48,4176,4177],{"class":261},"phone",[48,4179,66],{"class":54},[48,4181,70],{"class":69},[48,4183,4177],{"class":54},[48,4185,4186],{"class":69},"\"\n",[48,4188,4189,4191,4193,4195,4197,4199,4201],{"class":50,"line":87},[48,4190,4172],{"class":261},[48,4192,336],{"class":54},[48,4194,1953],{"class":261},[48,4196,66],{"class":54},[48,4198,70],{"class":69},[48,4200,1953],{"class":54},[48,4202,4186],{"class":69},[48,4204,4205,4208,4211,4213,4215,4217,4219,4221,4223,4225,4227,4230,4233],{"class":50,"line":93},[48,4206,4207],{"class":54},"  :",[48,4209,4210],{"class":261},"detector",[48,4212,66],{"class":54},[48,4214,70],{"class":69},[48,4216,624],{"class":54},[48,4218,3351],{"class":600},[48,4220,806],{"class":54},[48,4222,647],{"class":62},[48,4224,3366],{"class":332},[48,4226,526],{"class":54},[48,4228,4229],{"class":261},"resolve",[48,4231,4232],{"class":54},"(serverDetectedCountry.value)",[48,4234,4186],{"class":69},[48,4236,4237],{"class":50,"line":362},[48,4238,4239],{"class":54},"\u002F>\n",[11,4241,2251,4242,4244,4245,4248],{},[17,4243,4210],{}," prop replaces the built-in chain entirely. The component still runs the rest of the detection logic (sets the inferred country, uses it as a Tier 2 hint, etc.), but the ",[2087,4246,4247],{},"source"," of the answer becomes whatever you said it was.",[25,4250,4252],{"id":4251},"responsive-picker-one-component-two-presentations","Responsive picker: one component, two presentations",[11,4254,4255],{},"The old country dropdown was a popover at every viewport size. On a phone, that meant a 250-row scrollable list anchored to a 38-pixel-wide trigger button, with a search input that the on-screen keyboard kept occluding. It worked. It worked badly.",[11,4257,4258,4259,4262],{},"The new picker uses the ",[17,4260,4261],{},"AResponsivePopover"," primitive from the same library, which is a popover on desktop and a vaul-vue drawer on mobile:",[39,4264,4266],{"className":41,"code":4265,"language":43,"meta":44,"style":44},"\u003CAResponsivePopover v-model:open=\"open\">\n  \u003CAResponsivePopoverTrigger as-child>\n    \u003C!-- trigger button -->\n  \u003C\u002FAResponsivePopoverTrigger>\n\n  \u003CAResponsivePopoverContent\n    :popover-class=\"\n      cn(\n        'w-[min(20rem,calc(100vw-2rem))] max-h-[min(22rem,var(--reka-popover-content-available-height))]',\n        props.popoverClass\n      )\n    \"\n    :drawer-class=\"cn('max-h-[80vh] pb-4', props.drawerClass)\"\n  >\n    \u003C!-- search + list -->\n  \u003C\u002FAResponsivePopoverContent>\n\u003C\u002FAResponsivePopover>\n",[17,4267,4268,4292,4297,4302,4307,4311,4316,4321,4326,4331,4336,4341,4346,4351,4356,4361,4366],{"__ignoreMap":44},[48,4269,4270,4272,4274,4277,4279,4282,4284,4286,4288,4290],{"class":50,"line":51},[48,4271,55],{"class":54},[48,4273,4261],{"class":58},[48,4275,4276],{"class":261}," v-model",[48,4278,336],{"class":54},[48,4280,4281],{"class":261},"open",[48,4283,66],{"class":54},[48,4285,70],{"class":69},[48,4287,4281],{"class":54},[48,4289,70],{"class":69},[48,4291,78],{"class":54},[48,4293,4294],{"class":50,"line":81},[48,4295,4296],{"class":54},"  \u003CAResponsivePopoverTrigger as-child>\n",[48,4298,4299],{"class":50,"line":87},[48,4300,4301],{"class":54},"    \u003C!-- trigger button -->\n",[48,4303,4304],{"class":50,"line":93},[48,4305,4306],{"class":54},"  \u003C\u002FAResponsivePopoverTrigger>\n",[48,4308,4309],{"class":50,"line":362},[48,4310,324],{"emptyLinePlaceholder":323},[48,4312,4313],{"class":50,"line":373},[48,4314,4315],{"class":54},"  \u003CAResponsivePopoverContent\n",[48,4317,4318],{"class":50,"line":384},[48,4319,4320],{"class":54},"    :popover-class=\"\n",[48,4322,4323],{"class":50,"line":661},[48,4324,4325],{"class":54},"      cn(\n",[48,4327,4328],{"class":50,"line":690},[48,4329,4330],{"class":54},"        'w-[min(20rem,calc(100vw-2rem))] max-h-[min(22rem,var(--reka-popover-content-available-height))]',\n",[48,4332,4333],{"class":50,"line":695},[48,4334,4335],{"class":54},"        props.popoverClass\n",[48,4337,4338],{"class":50,"line":722},[48,4339,4340],{"class":54},"      )\n",[48,4342,4343],{"class":50,"line":743},[48,4344,4345],{"class":54},"    \"\n",[48,4347,4348],{"class":50,"line":767},[48,4349,4350],{"class":54},"    :drawer-class=\"cn('max-h-[80vh] pb-4', props.drawerClass)\"\n",[48,4352,4353],{"class":50,"line":778},[48,4354,4355],{"class":54},"  >\n",[48,4357,4358],{"class":50,"line":783},[48,4359,4360],{"class":54},"    \u003C!-- search + list -->\n",[48,4362,4363],{"class":50,"line":788},[48,4364,4365],{"class":54},"  \u003C\u002FAResponsivePopoverContent>\n",[48,4367,4368,4370,4372],{"class":50,"line":819},[48,4369,96],{"class":54},[48,4371,4261],{"class":58},[48,4373,78],{"class":54},[11,4375,4376,4377,1294,4380,4383,4384,4387,4388,4391],{},"Two class props (",[17,4378,4379],{},"popoverClass",[17,4381,4382],{},"drawerClass",") get forwarded separately, so consumers can theme each presentation without the other inheriting it. The popover wants ",[17,4385,4386],{},"max-h-[min(22rem,var(--reka-popover-content-available-height))]"," because reka-ui exposes the available height as a CSS variable; the drawer wants ",[17,4389,4390],{},"max-h-[80vh]"," because it's a bottom-sheet and \"80% of the viewport\" is a sensible cap. Forcing one set of classes onto both would be wrong for one of them; allowing the consumer to override either separately is exactly the seam a library needs.",[11,4393,4394],{},"The other side benefit is search. In drawer mode, the search input docks at the top of a sheet that takes up most of the screen, and the keyboard pushes it up rather than burying it. The same code, the same component, the right shape for the device. Not a media-query inside the consumer's CSS; a primitive that knows the difference.",[25,4396,4398],{"id":4397},"slots-for-everything-fourteen-of-them","Slots for everything (fourteen of them)",[11,4400,4401],{},"The old version was monolithic. Restyling the error message meant patching the component. Changing the chevron icon meant patching the component. Replacing the country flag with one from your own CDN meant patching the component.",[11,4403,4404],{},"The new version owns the behaviour and lets the consumer own the look:",[39,4406,4408],{"className":1888,"code":4407,"language":1890,"meta":44,"style":44},"defineSlots\u003C{\n  prefix?: () => unknown;\n  suffix?: (props: { validationState: 'idle' | 'valid' | 'error'; validation: PhoneValidationResult }) => unknown;\n  'valid-icon'?: () => unknown;\n  'error-icon'?: (props: { reason: string }) => unknown;\n  hint?: (props: { country: string; formatHint: string; example: string | null }) => unknown;\n  error?: (props: { message: string; reason: string; validation: PhoneValidationResult }) => unknown;\n  trigger?: (props: { selectedCountry: CountryOption | null; open: boolean; sizeClasses: string }) => unknown;\n  chevron?: (props: { open: boolean }) => unknown;\n  flag?: (props: { country: CountryOption; context: 'trigger' | 'item' }) => unknown;\n  item?: (props: { country: CountryOption; selected: boolean; disabled: boolean; select: () => void }) => unknown;\n  'group-header'?: (props: { label: string; group: 'suggested' | 'all' }) => unknown;\n  search?: (props: { value: string; setValue: (v: string) => void; isSearching: boolean }) => unknown;\n  loading?: () => unknown;\n  empty?: (props: { query: string }) => unknown;\n}>();\n",[17,4409,4410,4418,4435,4487,4502,4532,4583,4629,4682,4711,4755,4816,4861,4922,4937,4967],{"__ignoreMap":44},[48,4411,4412,4415],{"class":50,"line":51},[48,4413,4414],{"class":261},"defineSlots",[48,4416,4417],{"class":54},"\u003C{\n",[48,4419,4420,4423,4425,4428,4430,4433],{"class":50,"line":81},[48,4421,4422],{"class":261},"  prefix",[48,4424,3940],{"class":62},[48,4426,4427],{"class":54}," () ",[48,4429,647],{"class":62},[48,4431,4432],{"class":332}," unknown",[48,4434,318],{"class":54},[48,4436,4437,4440,4442,4444,4446,4448,4450,4453,4455,4458,4460,4463,4465,4468,4470,4473,4475,4478,4481,4483,4485],{"class":50,"line":87},[48,4438,4439],{"class":261},"  suffix",[48,4441,3940],{"class":62},[48,4443,2180],{"class":54},[48,4445,731],{"class":600},[48,4447,336],{"class":62},[48,4449,700],{"class":54},[48,4451,4452],{"class":600},"validationState",[48,4454,336],{"class":62},[48,4456,4457],{"class":69}," 'idle'",[48,4459,678],{"class":62},[48,4461,4462],{"class":69}," 'valid'",[48,4464,678],{"class":62},[48,4466,4467],{"class":69}," 'error'",[48,4469,3945],{"class":54},[48,4471,4472],{"class":600},"validation",[48,4474,336],{"class":62},[48,4476,4477],{"class":261}," PhoneValidationResult",[48,4479,4480],{"class":54}," }) ",[48,4482,647],{"class":62},[48,4484,4432],{"class":332},[48,4486,318],{"class":54},[48,4488,4489,4492,4494,4496,4498,4500],{"class":50,"line":93},[48,4490,4491],{"class":69},"  'valid-icon'",[48,4493,3940],{"class":62},[48,4495,4427],{"class":54},[48,4497,647],{"class":62},[48,4499,4432],{"class":332},[48,4501,318],{"class":54},[48,4503,4504,4507,4509,4511,4513,4515,4517,4520,4522,4524,4526,4528,4530],{"class":50,"line":362},[48,4505,4506],{"class":69},"  'error-icon'",[48,4508,3940],{"class":62},[48,4510,2180],{"class":54},[48,4512,731],{"class":600},[48,4514,336],{"class":62},[48,4516,700],{"class":54},[48,4518,4519],{"class":600},"reason",[48,4521,336],{"class":62},[48,4523,606],{"class":332},[48,4525,4480],{"class":54},[48,4527,647],{"class":62},[48,4529,4432],{"class":332},[48,4531,318],{"class":54},[48,4533,4534,4537,4539,4541,4543,4545,4547,4549,4551,4553,4555,4558,4560,4562,4564,4567,4569,4571,4573,4575,4577,4579,4581],{"class":50,"line":373},[48,4535,4536],{"class":261},"  hint",[48,4538,3940],{"class":62},[48,4540,2180],{"class":54},[48,4542,731],{"class":600},[48,4544,336],{"class":62},[48,4546,700],{"class":54},[48,4548,1953],{"class":600},[48,4550,336],{"class":62},[48,4552,606],{"class":332},[48,4554,3945],{"class":54},[48,4556,4557],{"class":600},"formatHint",[48,4559,336],{"class":62},[48,4561,606],{"class":332},[48,4563,3945],{"class":54},[48,4565,4566],{"class":600},"example",[48,4568,336],{"class":62},[48,4570,606],{"class":332},[48,4572,678],{"class":62},[48,4574,656],{"class":332},[48,4576,4480],{"class":54},[48,4578,647],{"class":62},[48,4580,4432],{"class":332},[48,4582,318],{"class":54},[48,4584,4585,4588,4590,4592,4594,4596,4598,4601,4603,4605,4607,4609,4611,4613,4615,4617,4619,4621,4623,4625,4627],{"class":50,"line":384},[48,4586,4587],{"class":261},"  error",[48,4589,3940],{"class":62},[48,4591,2180],{"class":54},[48,4593,731],{"class":600},[48,4595,336],{"class":62},[48,4597,700],{"class":54},[48,4599,4600],{"class":600},"message",[48,4602,336],{"class":62},[48,4604,606],{"class":332},[48,4606,3945],{"class":54},[48,4608,4519],{"class":600},[48,4610,336],{"class":62},[48,4612,606],{"class":332},[48,4614,3945],{"class":54},[48,4616,4472],{"class":600},[48,4618,336],{"class":62},[48,4620,4477],{"class":261},[48,4622,4480],{"class":54},[48,4624,647],{"class":62},[48,4626,4432],{"class":332},[48,4628,318],{"class":54},[48,4630,4631,4634,4636,4638,4640,4642,4644,4647,4649,4652,4654,4656,4658,4660,4662,4665,4667,4670,4672,4674,4676,4678,4680],{"class":50,"line":661},[48,4632,4633],{"class":261},"  trigger",[48,4635,3940],{"class":62},[48,4637,2180],{"class":54},[48,4639,731],{"class":600},[48,4641,336],{"class":62},[48,4643,700],{"class":54},[48,4645,4646],{"class":600},"selectedCountry",[48,4648,336],{"class":62},[48,4650,4651],{"class":261}," CountryOption",[48,4653,678],{"class":62},[48,4655,656],{"class":332},[48,4657,3945],{"class":54},[48,4659,4281],{"class":600},[48,4661,336],{"class":62},[48,4663,4664],{"class":332}," boolean",[48,4666,3945],{"class":54},[48,4668,4669],{"class":600},"sizeClasses",[48,4671,336],{"class":62},[48,4673,606],{"class":332},[48,4675,4480],{"class":54},[48,4677,647],{"class":62},[48,4679,4432],{"class":332},[48,4681,318],{"class":54},[48,4683,4684,4687,4689,4691,4693,4695,4697,4699,4701,4703,4705,4707,4709],{"class":50,"line":690},[48,4685,4686],{"class":261},"  chevron",[48,4688,3940],{"class":62},[48,4690,2180],{"class":54},[48,4692,731],{"class":600},[48,4694,336],{"class":62},[48,4696,700],{"class":54},[48,4698,4281],{"class":600},[48,4700,336],{"class":62},[48,4702,4664],{"class":332},[48,4704,4480],{"class":54},[48,4706,647],{"class":62},[48,4708,4432],{"class":332},[48,4710,318],{"class":54},[48,4712,4713,4716,4718,4720,4722,4724,4726,4728,4730,4732,4734,4737,4739,4742,4744,4747,4749,4751,4753],{"class":50,"line":695},[48,4714,4715],{"class":261},"  flag",[48,4717,3940],{"class":62},[48,4719,2180],{"class":54},[48,4721,731],{"class":600},[48,4723,336],{"class":62},[48,4725,700],{"class":54},[48,4727,1953],{"class":600},[48,4729,336],{"class":62},[48,4731,4651],{"class":261},[48,4733,3945],{"class":54},[48,4735,4736],{"class":600},"context",[48,4738,336],{"class":62},[48,4740,4741],{"class":69}," 'trigger'",[48,4743,678],{"class":62},[48,4745,4746],{"class":69}," 'item'",[48,4748,4480],{"class":54},[48,4750,647],{"class":62},[48,4752,4432],{"class":332},[48,4754,318],{"class":54},[48,4756,4757,4760,4762,4764,4766,4768,4770,4772,4774,4776,4778,4781,4783,4785,4787,4790,4792,4794,4796,4799,4801,4803,4805,4808,4810,4812,4814],{"class":50,"line":722},[48,4758,4759],{"class":261},"  item",[48,4761,3940],{"class":62},[48,4763,2180],{"class":54},[48,4765,731],{"class":600},[48,4767,336],{"class":62},[48,4769,700],{"class":54},[48,4771,1953],{"class":600},[48,4773,336],{"class":62},[48,4775,4651],{"class":261},[48,4777,3945],{"class":54},[48,4779,4780],{"class":600},"selected",[48,4782,336],{"class":62},[48,4784,4664],{"class":332},[48,4786,3945],{"class":54},[48,4788,4789],{"class":600},"disabled",[48,4791,336],{"class":62},[48,4793,4664],{"class":332},[48,4795,3945],{"class":54},[48,4797,4798],{"class":261},"select",[48,4800,336],{"class":62},[48,4802,4427],{"class":54},[48,4804,647],{"class":62},[48,4806,4807],{"class":332}," void",[48,4809,4480],{"class":54},[48,4811,647],{"class":62},[48,4813,4432],{"class":332},[48,4815,318],{"class":54},[48,4817,4818,4821,4823,4825,4827,4829,4831,4834,4836,4838,4840,4843,4845,4848,4850,4853,4855,4857,4859],{"class":50,"line":743},[48,4819,4820],{"class":69},"  'group-header'",[48,4822,3940],{"class":62},[48,4824,2180],{"class":54},[48,4826,731],{"class":600},[48,4828,336],{"class":62},[48,4830,700],{"class":54},[48,4832,4833],{"class":600},"label",[48,4835,336],{"class":62},[48,4837,606],{"class":332},[48,4839,3945],{"class":54},[48,4841,4842],{"class":600},"group",[48,4844,336],{"class":62},[48,4846,4847],{"class":69}," 'suggested'",[48,4849,678],{"class":62},[48,4851,4852],{"class":69}," 'all'",[48,4854,4480],{"class":54},[48,4856,647],{"class":62},[48,4858,4432],{"class":332},[48,4860,318],{"class":54},[48,4862,4863,4866,4868,4870,4872,4874,4876,4879,4881,4883,4885,4888,4890,4892,4895,4897,4899,4901,4903,4905,4907,4910,4912,4914,4916,4918,4920],{"class":50,"line":767},[48,4864,4865],{"class":261},"  search",[48,4867,3940],{"class":62},[48,4869,2180],{"class":54},[48,4871,731],{"class":600},[48,4873,336],{"class":62},[48,4875,700],{"class":54},[48,4877,4878],{"class":600},"value",[48,4880,336],{"class":62},[48,4882,606],{"class":332},[48,4884,3945],{"class":54},[48,4886,4887],{"class":261},"setValue",[48,4889,336],{"class":62},[48,4891,2180],{"class":54},[48,4893,4894],{"class":600},"v",[48,4896,336],{"class":62},[48,4898,606],{"class":332},[48,4900,806],{"class":54},[48,4902,647],{"class":62},[48,4904,4807],{"class":332},[48,4906,3945],{"class":54},[48,4908,4909],{"class":600},"isSearching",[48,4911,336],{"class":62},[48,4913,4664],{"class":332},[48,4915,4480],{"class":54},[48,4917,647],{"class":62},[48,4919,4432],{"class":332},[48,4921,318],{"class":54},[48,4923,4924,4927,4929,4931,4933,4935],{"class":50,"line":778},[48,4925,4926],{"class":261},"  loading",[48,4928,3940],{"class":62},[48,4930,4427],{"class":54},[48,4932,647],{"class":62},[48,4934,4432],{"class":332},[48,4936,318],{"class":54},[48,4938,4939,4942,4944,4946,4948,4950,4952,4955,4957,4959,4961,4963,4965],{"class":50,"line":783},[48,4940,4941],{"class":261},"  empty",[48,4943,3940],{"class":62},[48,4945,2180],{"class":54},[48,4947,731],{"class":600},[48,4949,336],{"class":62},[48,4951,700],{"class":54},[48,4953,4954],{"class":600},"query",[48,4956,336],{"class":62},[48,4958,606],{"class":332},[48,4960,4480],{"class":54},[48,4962,647],{"class":62},[48,4964,4432],{"class":332},[48,4966,318],{"class":54},[48,4968,4969],{"class":50,"line":788},[48,4970,4971],{"class":54},"}>();\n",[11,4973,4974,4975,158,4978,4981,4982,158,4985,4988,4989,158,4992,158,4995,4998,4999,158,5002,158,5004,158,5007,158,5010,5013],{},"Fourteen slots, three groups. The visual ones (",[17,4976,4977],{},"prefix",[17,4979,4980],{},"suffix",", the two icon slots, ",[17,4983,4984],{},"hint",[17,4986,4987],{},"error",") decorate the field itself. The picker-trigger ones (",[17,4990,4991],{},"trigger",[17,4993,4994],{},"chevron",[17,4996,4997],{},"flag",") replace the button users click. The picker-content ones (",[17,5000,5001],{},"search",[17,5003,262],{},[17,5005,5006],{},"empty",[17,5008,5009],{},"group-header",[17,5011,5012],{},"item",") replace anything inside the popover or drawer.",[11,5015,5016,5017,5020],{},"Almost all of the picker slots forward straight to ",[17,5018,5019],{},"ACountrySelect",". The component itself does the routing:",[39,5022,5024],{"className":41,"code":5023,"language":43,"meta":44,"style":44},"\u003Ctemplate v-if=\"$slots.trigger\" #trigger=\"slotProps\">\n  \u003Cslot name=\"trigger\" v-bind=\"slotProps\" \u002F>\n\u003C\u002Ftemplate>\n\u003Ctemplate v-if=\"$slots.chevron\" #chevron=\"slotProps\">\n  \u003Cslot name=\"chevron\" v-bind=\"slotProps\" \u002F>\n\u003C\u002Ftemplate>\n\u003C!-- ... and so on for each forwarded slot -->\n",[17,5025,5026,5059,5086,5094,5125,5148,5156],{"__ignoreMap":44},[48,5027,5028,5030,5032,5034,5036,5038,5041,5043,5046,5048,5050,5052,5055,5057],{"class":50,"line":51},[48,5029,55],{"class":54},[48,5031,838],{"class":58},[48,5033,63],{"class":62},[48,5035,66],{"class":54},[48,5037,70],{"class":69},[48,5039,5040],{"class":54},"$slots.trigger",[48,5042,70],{"class":69},[48,5044,5045],{"class":54}," #",[48,5047,4991],{"class":261},[48,5049,66],{"class":54},[48,5051,70],{"class":69},[48,5053,5054],{"class":54},"slotProps",[48,5056,70],{"class":69},[48,5058,78],{"class":54},[48,5060,5061,5063,5066,5069,5071,5074,5077,5079,5082,5084],{"class":50,"line":81},[48,5062,846],{"class":54},[48,5064,5065],{"class":58},"slot",[48,5067,5068],{"class":261}," name",[48,5070,66],{"class":54},[48,5072,5073],{"class":69},"\"trigger\"",[48,5075,5076],{"class":261}," v-bind",[48,5078,66],{"class":54},[48,5080,5081],{"class":69},"\"slotProps\"",[48,5083,1116],{"class":1115},[48,5085,78],{"class":54},[48,5087,5088,5090,5092],{"class":50,"line":87},[48,5089,96],{"class":54},[48,5091,838],{"class":58},[48,5093,78],{"class":54},[48,5095,5096,5098,5100,5102,5104,5106,5109,5111,5113,5115,5117,5119,5121,5123],{"class":50,"line":93},[48,5097,55],{"class":54},[48,5099,838],{"class":58},[48,5101,63],{"class":62},[48,5103,66],{"class":54},[48,5105,70],{"class":69},[48,5107,5108],{"class":54},"$slots.chevron",[48,5110,70],{"class":69},[48,5112,5045],{"class":54},[48,5114,4994],{"class":261},[48,5116,66],{"class":54},[48,5118,70],{"class":69},[48,5120,5054],{"class":54},[48,5122,70],{"class":69},[48,5124,78],{"class":54},[48,5126,5127,5129,5131,5133,5135,5138,5140,5142,5144,5146],{"class":50,"line":362},[48,5128,846],{"class":54},[48,5130,5065],{"class":58},[48,5132,5068],{"class":261},[48,5134,66],{"class":54},[48,5136,5137],{"class":69},"\"chevron\"",[48,5139,5076],{"class":261},[48,5141,66],{"class":54},[48,5143,5081],{"class":69},[48,5145,1116],{"class":1115},[48,5147,78],{"class":54},[48,5149,5150,5152,5154],{"class":50,"line":373},[48,5151,96],{"class":54},[48,5153,838],{"class":58},[48,5155,78],{"class":54},[48,5157,5158],{"class":50,"line":384},[48,5159,5160],{"class":2473},"\u003C!-- ... and so on for each forwarded slot -->\n",[11,5162,2251,5163,5166,5167,5170,5171,5173],{},[17,5164,5165],{},"v-if=\"$slots.x\""," matters. Without it, a forwarded ",[17,5168,5169],{},"\u003Ctemplate #x>"," always exists from the parent's perspective, and ",[17,5172,5019],{},"'s own fallback rendering never gets a chance to run. The guard says \"only forward if the consumer actually provided this slot.\"",[11,5175,5176],{},"A realistic customization:",[39,5178,5180],{"className":41,"code":5179,"language":43,"meta":44,"style":44},"\u003CATellInput v-model:phone=\"phone\" v-model:country=\"country\" show-validation>\n  \u003Ctemplate #suffix=\"{ validationState }\">\n    \u003CSparkle v-if=\"validationState === 'valid'\" class=\"size-4 text-amber-400\" \u002F>\n  \u003C\u002Ftemplate>\n  \u003Ctemplate #error=\"{ message, reason }\">\n    \u003Cp class=\"text-destructive text-xs\">\n      \u003Ccode class=\"bg-muted rounded px-1 py-0.5\">{{ reason }}\u003C\u002Fcode> {{ message }}\n    \u003C\u002Fp>\n  \u003C\u002Ftemplate>\n  \u003Ctemplate #empty=\"{ query }\">\n    \u003Cdiv class=\"px-3 py-6 text-center text-sm\">\n      Nothing matched \"\u003Cstrong>{{ query }}\u003C\u002Fstrong>\".\n      \u003Cbutton type=\"button\" class=\"underline\" @click=\"suggestCountry(query)\">\n        Suggest a country\n      \u003C\u002Fbutton>\n    \u003C\u002Fdiv>\n  \u003C\u002Ftemplate>\n\u003C\u002FATellInput>\n",[17,5181,5182,5221,5226,5231,5236,5241,5246,5251,5256,5260,5265,5270,5275,5280,5285,5290,5295,5299],{"__ignoreMap":44},[48,5183,5184,5186,5188,5190,5192,5194,5196,5198,5200,5202,5204,5206,5208,5210,5212,5214,5216,5219],{"class":50,"line":51},[48,5185,55],{"class":54},[48,5187,1864],{"class":58},[48,5189,4276],{"class":261},[48,5191,336],{"class":54},[48,5193,4177],{"class":261},[48,5195,66],{"class":54},[48,5197,70],{"class":69},[48,5199,4177],{"class":54},[48,5201,70],{"class":69},[48,5203,4276],{"class":261},[48,5205,336],{"class":54},[48,5207,1953],{"class":261},[48,5209,66],{"class":54},[48,5211,70],{"class":69},[48,5213,1953],{"class":54},[48,5215,70],{"class":69},[48,5217,5218],{"class":261}," show-validation",[48,5220,78],{"class":54},[48,5222,5223],{"class":50,"line":81},[48,5224,5225],{"class":54},"  \u003Ctemplate #suffix=\"{ validationState }\">\n",[48,5227,5228],{"class":50,"line":87},[48,5229,5230],{"class":54},"    \u003CSparkle v-if=\"validationState === 'valid'\" class=\"size-4 text-amber-400\" \u002F>\n",[48,5232,5233],{"class":50,"line":93},[48,5234,5235],{"class":54},"  \u003C\u002Ftemplate>\n",[48,5237,5238],{"class":50,"line":362},[48,5239,5240],{"class":54},"  \u003Ctemplate #error=\"{ message, reason }\">\n",[48,5242,5243],{"class":50,"line":373},[48,5244,5245],{"class":54},"    \u003Cp class=\"text-destructive text-xs\">\n",[48,5247,5248],{"class":50,"line":384},[48,5249,5250],{"class":54},"      \u003Ccode class=\"bg-muted rounded px-1 py-0.5\">{{ reason }}\u003C\u002Fcode> {{ message }}\n",[48,5252,5253],{"class":50,"line":661},[48,5254,5255],{"class":54},"    \u003C\u002Fp>\n",[48,5257,5258],{"class":50,"line":690},[48,5259,5235],{"class":54},[48,5261,5262],{"class":50,"line":695},[48,5263,5264],{"class":54},"  \u003Ctemplate #empty=\"{ query }\">\n",[48,5266,5267],{"class":50,"line":722},[48,5268,5269],{"class":54},"    \u003Cdiv class=\"px-3 py-6 text-center text-sm\">\n",[48,5271,5272],{"class":50,"line":743},[48,5273,5274],{"class":54},"      Nothing matched \"\u003Cstrong>{{ query }}\u003C\u002Fstrong>\".\n",[48,5276,5277],{"class":50,"line":767},[48,5278,5279],{"class":54},"      \u003Cbutton type=\"button\" class=\"underline\" @click=\"suggestCountry(query)\">\n",[48,5281,5282],{"class":50,"line":778},[48,5283,5284],{"class":54},"        Suggest a country\n",[48,5286,5287],{"class":50,"line":783},[48,5288,5289],{"class":54},"      \u003C\u002Fbutton>\n",[48,5291,5292],{"class":50,"line":788},[48,5293,5294],{"class":54},"    \u003C\u002Fdiv>\n",[48,5296,5297],{"class":50,"line":819},[48,5298,5235],{"class":54},[48,5300,5301,5303,5305],{"class":50,"line":828},[48,5302,96],{"class":54},[48,5304,1864],{"class":58},[48,5306,78],{"class":54},[11,5308,5309,5310,5313],{},"A library component's job is to own ",[2087,5311,5312],{},"behaviour"," (the parsing, the validation, the detection, the state) and to leave the consumer in charge of what those things look like. Fourteen slots is what that bargain costs, and it's worth it.",[11,5315,5316],{},"The same principle applies to the props that aren't visual:",[39,5318,5320],{"className":1888,"code":5319,"language":1890,"meta":44,"style":44},"flagUrl?: (iso2: string, width: number) => string;\nsearcher?: (query: string, country: CountryOption) => boolean;\ncountries?: CountryOption[];\ndetector?: (options: DetectCountryOptions) => Promise\u003Cstring | null | undefined>;\nerrorMessages?: Record\u003CPhoneValidationReason, string>;\n",[17,5321,5322,5354,5384,5394,5435],{"__ignoreMap":44},[48,5323,5324,5327,5329,5331,5334,5336,5338,5340,5343,5345,5347,5349,5351],{"class":50,"line":51},[48,5325,5326],{"class":54},"flagUrl",[48,5328,3940],{"class":62},[48,5330,2180],{"class":54},[48,5332,5333],{"class":600},"iso2",[48,5335,336],{"class":62},[48,5337,606],{"class":332},[48,5339,158],{"class":54},[48,5341,5342],{"class":600},"width",[48,5344,336],{"class":62},[48,5346,3769],{"class":332},[48,5348,806],{"class":54},[48,5350,647],{"class":62},[48,5352,5353],{"class":54}," string;\n",[48,5355,5356,5359,5361,5363,5365,5367,5369,5371,5373,5375,5377,5379,5381],{"class":50,"line":81},[48,5357,5358],{"class":54},"searcher",[48,5360,3940],{"class":62},[48,5362,2180],{"class":54},[48,5364,4954],{"class":600},[48,5366,336],{"class":62},[48,5368,606],{"class":332},[48,5370,158],{"class":54},[48,5372,1953],{"class":600},[48,5374,336],{"class":62},[48,5376,4651],{"class":261},[48,5378,806],{"class":54},[48,5380,647],{"class":62},[48,5382,5383],{"class":54}," boolean;\n",[48,5385,5386,5389,5391],{"class":50,"line":87},[48,5387,5388],{"class":54},"countries",[48,5390,3940],{"class":62},[48,5392,5393],{"class":54}," CountryOption[];\n",[48,5395,5396,5398,5400,5402,5405,5407,5409,5411,5413,5415,5417,5420,5423,5425,5427,5430,5433],{"class":50,"line":93},[48,5397,4210],{"class":54},[48,5399,3940],{"class":62},[48,5401,2180],{"class":54},[48,5403,5404],{"class":600},"options",[48,5406,336],{"class":62},[48,5408,3356],{"class":261},[48,5410,806],{"class":54},[48,5412,647],{"class":62},[48,5414,3366],{"class":332},[48,5416,55],{"class":62},[48,5418,5419],{"class":54},"string ",[48,5421,5422],{"class":62},"|",[48,5424,656],{"class":332},[48,5426,678],{"class":62},[48,5428,5429],{"class":332}," undefined",[48,5431,5432],{"class":62},">",[48,5434,318],{"class":54},[48,5436,5437,5440,5442,5445,5447,5450,5452],{"class":50,"line":362},[48,5438,5439],{"class":54},"errorMessages",[48,5441,3940],{"class":62},[48,5443,5444],{"class":54}," Record",[48,5446,55],{"class":62},[48,5448,5449],{"class":54},"PhoneValidationReason, string",[48,5451,5432],{"class":62},[48,5453,318],{"class":54},[11,5455,5456,5458,5459,5461,5462,5464,5465,5467],{},[17,5457,5326],{}," lets you swap flagcdn.com for your own CDN; useful if you have a CSP that won't allow third-party images. ",[17,5460,5358],{}," lets you replace substring-matching with prefix-matching, fuzzy-matching, Arabic-name matching, whatever your users actually want. ",[17,5463,5388],{}," lets you ship your own curated list and skip the REST Countries fetch entirely; useful if you've already decided you only serve, say, Gulf countries. ",[17,5466,5439],{}," is i18n: pass an Arabic table and the validation reasons render in Arabic.",[11,5469,5470],{},"Every prop has a sensible default. The zero-config call still works.",[25,5472,5474],{"id":5473},"the-synchronous-dial-table-and-why-its-not-optional","The synchronous dial table, and why it's not optional",[11,5476,5477,5478,5481,5482,158,5485,5488,5489,5492,5493,5496,5497,3696,5499,5501],{},"The first time the component mounts in a fresh tab, the REST Countries fetch hasn't completed. Whatever indices ",[17,5479,5480],{},"usePhoneValidation"," builds (",[17,5483,5484],{},"byValue",[17,5486,5487],{},"byDialDigits",") are empty maps. If the consumer wrote ",[17,5490,5491],{},"default-country=\"20\""," in their template, expecting the picker to render with Egypt selected, what they'd get without help is an empty picker. The dial-digits index would return ",[17,5494,5495],{},"[]",", the watcher would set ",[17,5498,2097],{},[17,5500,2014],{},", and Egypt wouldn't appear until the network call returned.",[11,5503,5504],{},"So the component bakes a synchronous fallback table:",[39,5506,5508],{"className":1888,"code":5507,"language":1890,"meta":44,"style":44},"const DIAL_TO_ISO2_FALLBACK: Record\u003Cstring, string> = {\n  '1': 'US',\n  '7': 'RU',\n  '20': 'EG',\n  '27': 'ZA',\n  '33': 'FR',\n  '44': 'GB',\n  '49': 'DE',\n  '52': 'MX',\n  '55': 'BR',\n  '61': 'AU',\n  '81': 'JP',\n  '86': 'CN',\n  '91': 'IN',\n  '212': 'MA',\n  '966': 'SA',\n  '971': 'AE',\n  \u002F\u002F ... ~50 entries\n};\n",[17,5509,5510,5536,5548,5560,5572,5584,5596,5608,5620,5632,5644,5656,5668,5680,5692,5704,5716,5728,5733],{"__ignoreMap":44},[48,5511,5512,5514,5517,5519,5521,5523,5525,5527,5529,5532,5534],{"class":50,"line":51},[48,5513,329],{"class":62},[48,5515,5516],{"class":332}," DIAL_TO_ISO2_FALLBACK",[48,5518,336],{"class":62},[48,5520,5444],{"class":261},[48,5522,55],{"class":54},[48,5524,1909],{"class":332},[48,5526,158],{"class":54},[48,5528,1909],{"class":332},[48,5530,5531],{"class":54},"> ",[48,5533,66],{"class":62},[48,5535,1623],{"class":54},[48,5537,5538,5541,5543,5546],{"class":50,"line":81},[48,5539,5540],{"class":69},"  '1'",[48,5542,1631],{"class":54},[48,5544,5545],{"class":69},"'US'",[48,5547,359],{"class":54},[48,5549,5550,5553,5555,5558],{"class":50,"line":87},[48,5551,5552],{"class":69},"  '7'",[48,5554,1631],{"class":54},[48,5556,5557],{"class":69},"'RU'",[48,5559,359],{"class":54},[48,5561,5562,5565,5567,5570],{"class":50,"line":93},[48,5563,5564],{"class":69},"  '20'",[48,5566,1631],{"class":54},[48,5568,5569],{"class":69},"'EG'",[48,5571,359],{"class":54},[48,5573,5574,5577,5579,5582],{"class":50,"line":362},[48,5575,5576],{"class":69},"  '27'",[48,5578,1631],{"class":54},[48,5580,5581],{"class":69},"'ZA'",[48,5583,359],{"class":54},[48,5585,5586,5589,5591,5594],{"class":50,"line":373},[48,5587,5588],{"class":69},"  '33'",[48,5590,1631],{"class":54},[48,5592,5593],{"class":69},"'FR'",[48,5595,359],{"class":54},[48,5597,5598,5601,5603,5606],{"class":50,"line":384},[48,5599,5600],{"class":69},"  '44'",[48,5602,1631],{"class":54},[48,5604,5605],{"class":69},"'GB'",[48,5607,359],{"class":54},[48,5609,5610,5613,5615,5618],{"class":50,"line":661},[48,5611,5612],{"class":69},"  '49'",[48,5614,1631],{"class":54},[48,5616,5617],{"class":69},"'DE'",[48,5619,359],{"class":54},[48,5621,5622,5625,5627,5630],{"class":50,"line":690},[48,5623,5624],{"class":69},"  '52'",[48,5626,1631],{"class":54},[48,5628,5629],{"class":69},"'MX'",[48,5631,359],{"class":54},[48,5633,5634,5637,5639,5642],{"class":50,"line":695},[48,5635,5636],{"class":69},"  '55'",[48,5638,1631],{"class":54},[48,5640,5641],{"class":69},"'BR'",[48,5643,359],{"class":54},[48,5645,5646,5649,5651,5654],{"class":50,"line":722},[48,5647,5648],{"class":69},"  '61'",[48,5650,1631],{"class":54},[48,5652,5653],{"class":69},"'AU'",[48,5655,359],{"class":54},[48,5657,5658,5661,5663,5666],{"class":50,"line":743},[48,5659,5660],{"class":69},"  '81'",[48,5662,1631],{"class":54},[48,5664,5665],{"class":69},"'JP'",[48,5667,359],{"class":54},[48,5669,5670,5673,5675,5678],{"class":50,"line":767},[48,5671,5672],{"class":69},"  '86'",[48,5674,1631],{"class":54},[48,5676,5677],{"class":69},"'CN'",[48,5679,359],{"class":54},[48,5681,5682,5685,5687,5690],{"class":50,"line":778},[48,5683,5684],{"class":69},"  '91'",[48,5686,1631],{"class":54},[48,5688,5689],{"class":69},"'IN'",[48,5691,359],{"class":54},[48,5693,5694,5697,5699,5702],{"class":50,"line":783},[48,5695,5696],{"class":69},"  '212'",[48,5698,1631],{"class":54},[48,5700,5701],{"class":69},"'MA'",[48,5703,359],{"class":54},[48,5705,5706,5709,5711,5714],{"class":50,"line":788},[48,5707,5708],{"class":69},"  '966'",[48,5710,1631],{"class":54},[48,5712,5713],{"class":69},"'SA'",[48,5715,359],{"class":54},[48,5717,5718,5721,5723,5726],{"class":50,"line":819},[48,5719,5720],{"class":69},"  '971'",[48,5722,1631],{"class":54},[48,5724,5725],{"class":69},"'AE'",[48,5727,359],{"class":54},[48,5729,5730],{"class":50,"line":828},[48,5731,5732],{"class":2473},"  \u002F\u002F ... ~50 entries\n",[48,5734,5735],{"class":50,"line":833},[48,5736,5737],{"class":54},"};\n",[11,5739,5740,5741,5744],{},"And ",[17,5742,5743],{},"resolveCountryIdentifier"," checks the loaded index first, then this table:",[39,5746,5748],{"className":1888,"code":5747,"language":1890,"meta":44,"style":44},"function resolveCountryIdentifier(raw: string | undefined | null): string {\n  const v = String(raw ?? '').trim();\n  if (!v) return '';\n  if (\u002F^[A-Za-z]{2}$\u002F.test(v)) return v.toUpperCase();\n  const dial = v.replace(\u002F^\\+\u002F, '');\n  if (!\u002F^\\d+$\u002F.test(dial)) return '';\n  const match = getCountriesByDial(dial)[0];\n  if (match) return match.value;\n  return DIAL_TO_ISO2_FALLBACK[dial] ?? '';\n}\n",[17,5749,5750,5781,5807,5824,5857,5889,5922,5940,5952,5967],{"__ignoreMap":44},[48,5751,5752,5754,5756,5758,5761,5763,5765,5767,5769,5771,5773,5775,5777,5779],{"class":50,"line":51},[48,5753,2419],{"class":62},[48,5755,2209],{"class":261},[48,5757,624],{"class":54},[48,5759,5760],{"class":600},"raw",[48,5762,336],{"class":62},[48,5764,606],{"class":332},[48,5766,678],{"class":62},[48,5768,5429],{"class":332},[48,5770,678],{"class":62},[48,5772,656],{"class":332},[48,5774,2434],{"class":54},[48,5776,336],{"class":62},[48,5778,606],{"class":332},[48,5780,1623],{"class":54},[48,5782,5783,5785,5788,5790,5793,5796,5798,5800,5802,5805],{"class":50,"line":81},[48,5784,2604],{"class":62},[48,5786,5787],{"class":332}," v",[48,5789,342],{"class":62},[48,5791,5792],{"class":261}," String",[48,5794,5795],{"class":54},"(raw ",[48,5797,2556],{"class":62},[48,5799,2159],{"class":69},[48,5801,1251],{"class":54},[48,5803,5804],{"class":261},"trim",[48,5806,3473],{"class":54},[48,5808,5809,5811,5813,5815,5818,5820,5822],{"class":50,"line":87},[48,5810,2450],{"class":62},[48,5812,2180],{"class":54},[48,5814,2455],{"class":62},[48,5816,5817],{"class":54},"v) ",[48,5819,2194],{"class":62},[48,5821,2159],{"class":69},[48,5823,318],{"class":54},[48,5825,5826,5828,5830,5832,5834,5837,5839,5841,5843,5845,5848,5850,5853,5855],{"class":50,"line":93},[48,5827,2450],{"class":62},[48,5829,2180],{"class":54},[48,5831,4005],{"class":69},[48,5833,3996],{"class":62},[48,5835,5836],{"class":332},"[A-Za-z]",[48,5838,4002],{"class":62},[48,5840,4005],{"class":69},[48,5842,526],{"class":54},[48,5844,4010],{"class":261},[48,5846,5847],{"class":54},"(v)) ",[48,5849,2194],{"class":62},[48,5851,5852],{"class":54}," v.",[48,5854,3517],{"class":261},[48,5856,3473],{"class":54},[48,5858,5859,5861,5864,5866,5868,5871,5873,5875,5877,5881,5883,5885,5887],{"class":50,"line":362},[48,5860,2604],{"class":62},[48,5862,5863],{"class":332}," dial",[48,5865,342],{"class":62},[48,5867,5852],{"class":54},[48,5869,5870],{"class":261},"replace",[48,5872,624],{"class":54},[48,5874,4005],{"class":69},[48,5876,3996],{"class":62},[48,5878,5880],{"class":5879},"snhLl","\\+",[48,5882,4005],{"class":69},[48,5884,158],{"class":54},[48,5886,2014],{"class":69},[48,5888,629],{"class":54},[48,5890,5891,5893,5895,5897,5899,5901,5904,5907,5909,5911,5913,5916,5918,5920],{"class":50,"line":373},[48,5892,2450],{"class":62},[48,5894,2180],{"class":54},[48,5896,2455],{"class":62},[48,5898,4005],{"class":69},[48,5900,3996],{"class":62},[48,5902,5903],{"class":332},"\\d",[48,5905,5906],{"class":62},"+$",[48,5908,4005],{"class":69},[48,5910,526],{"class":54},[48,5912,4010],{"class":261},[48,5914,5915],{"class":54},"(dial)) ",[48,5917,2194],{"class":62},[48,5919,2159],{"class":69},[48,5921,318],{"class":54},[48,5923,5924,5926,5928,5930,5932,5935,5937],{"class":50,"line":384},[48,5925,2604],{"class":62},[48,5927,3135],{"class":332},[48,5929,342],{"class":62},[48,5931,2856],{"class":261},[48,5933,5934],{"class":54},"(dial)[",[48,5936,2840],{"class":332},[48,5938,5939],{"class":54},"];\n",[48,5941,5942,5944,5947,5949],{"class":50,"line":661},[48,5943,2450],{"class":62},[48,5945,5946],{"class":54}," (match) ",[48,5948,2194],{"class":62},[48,5950,5951],{"class":54}," match.value;\n",[48,5953,5954,5956,5958,5961,5963,5965],{"class":50,"line":690},[48,5955,2963],{"class":62},[48,5957,5516],{"class":332},[48,5959,5960],{"class":54},"[dial] ",[48,5962,2556],{"class":62},[48,5964,2159],{"class":69},[48,5966,318],{"class":54},[48,5968,5969],{"class":50,"line":695},[48,5970,1717],{"class":54},[11,5972,5973],{},"The reverse, going from an ISO2 back to a dial number for the v-model, does the same check:",[39,5975,5977],{"className":1888,"code":5976,"language":1890,"meta":44,"style":44},"function dialNumberFor(iso2: string): number | null {\n  if (!iso2) return null;\n  const fromIndex = getCountryByValue(iso2)?.raw_data?.dial_digits;\n  const digits = fromIndex ?? Object.entries(DIAL_TO_ISO2_FALLBACK).find(([, v]) => v === iso2)?.[0];\n  if (!digits) return null;\n  const n = Number(digits);\n  return Number.isFinite(n) ? n : null;\n}\n",[17,5978,5979,6006,6023,6037,6089,6105,6120,6144],{"__ignoreMap":44},[48,5980,5981,5983,5986,5988,5990,5992,5994,5996,5998,6000,6002,6004],{"class":50,"line":51},[48,5982,2419],{"class":62},[48,5984,5985],{"class":261}," dialNumberFor",[48,5987,624],{"class":54},[48,5989,5333],{"class":600},[48,5991,336],{"class":62},[48,5993,606],{"class":332},[48,5995,2434],{"class":54},[48,5997,336],{"class":62},[48,5999,3769],{"class":332},[48,6001,678],{"class":62},[48,6003,656],{"class":332},[48,6005,1623],{"class":54},[48,6007,6008,6010,6012,6014,6017,6019,6021],{"class":50,"line":81},[48,6009,2450],{"class":62},[48,6011,2180],{"class":54},[48,6013,2455],{"class":62},[48,6015,6016],{"class":54},"iso2) ",[48,6018,2194],{"class":62},[48,6020,656],{"class":332},[48,6022,318],{"class":54},[48,6024,6025,6027,6030,6032,6034],{"class":50,"line":87},[48,6026,2604],{"class":62},[48,6028,6029],{"class":332}," fromIndex",[48,6031,342],{"class":62},[48,6033,2530],{"class":261},[48,6035,6036],{"class":54},"(iso2)?.raw_data?.dial_digits;\n",[48,6038,6039,6041,6044,6046,6049,6051,6054,6057,6059,6062,6064,6067,6070,6072,6075,6077,6080,6082,6085,6087],{"class":50,"line":93},[48,6040,2604],{"class":62},[48,6042,6043],{"class":332}," digits",[48,6045,342],{"class":62},[48,6047,6048],{"class":54}," fromIndex ",[48,6050,2556],{"class":62},[48,6052,6053],{"class":54}," Object.",[48,6055,6056],{"class":261},"entries",[48,6058,624],{"class":54},[48,6060,6061],{"class":332},"DIAL_TO_ISO2_FALLBACK",[48,6063,1251],{"class":54},[48,6065,6066],{"class":261},"find",[48,6068,6069],{"class":54},"(([, ",[48,6071,4894],{"class":600},[48,6073,6074],{"class":54},"]) ",[48,6076,647],{"class":62},[48,6078,6079],{"class":54}," v ",[48,6081,653],{"class":62},[48,6083,6084],{"class":54}," iso2)?.[",[48,6086,2840],{"class":332},[48,6088,5939],{"class":54},[48,6090,6091,6093,6095,6097,6099,6101,6103],{"class":50,"line":362},[48,6092,2450],{"class":62},[48,6094,2180],{"class":54},[48,6096,2455],{"class":62},[48,6098,2458],{"class":54},[48,6100,2194],{"class":62},[48,6102,656],{"class":332},[48,6104,318],{"class":54},[48,6106,6107,6109,6112,6114,6117],{"class":50,"line":373},[48,6108,2604],{"class":62},[48,6110,6111],{"class":332}," n",[48,6113,342],{"class":62},[48,6115,6116],{"class":261}," Number",[48,6118,6119],{"class":54},"(digits);\n",[48,6121,6122,6124,6127,6130,6133,6135,6138,6140,6142],{"class":50,"line":384},[48,6123,2963],{"class":62},[48,6125,6126],{"class":54}," Number.",[48,6128,6129],{"class":261},"isFinite",[48,6131,6132],{"class":54},"(n) ",[48,6134,757],{"class":62},[48,6136,6137],{"class":54}," n ",[48,6139,336],{"class":62},[48,6141,656],{"class":332},[48,6143,318],{"class":54},[48,6145,6146],{"class":50,"line":661},[48,6147,1717],{"class":54},[11,6149,6150,6151,6154],{},"The table is a known compromise. Fifty entries can't cover every dial code, and the choice of which fifty is opinionated: the most-populated countries plus the ones I personally need. A consumer who passes ",[17,6152,6153],{},"default-country=\"678\""," (Vanuatu) at mount with no cached country data will get nothing back from this lookup. A few seconds later, when the network call returns, the picker will resolve correctly. The fallback exists to make the 95% case work without a flicker; the long tail still ends up correct.",[11,6156,6157],{},"In-app, you can wait for the fetch. As a library, you can't make every consumer's mount block on a third-party API, and you can't ship 80KB of country data eagerly either. The sync table is the third path.",[25,6159,6161],{"id":6160},"recents-as-the-tiebreaker","Recents as the tiebreaker",[11,6163,6164,6165,6167],{},"The longest-prefix Tier 3 match isn't always unambiguous. ",[17,6166,2082],{}," matches the entire NANP block, 25-plus countries. Alphabetical-first would mean \"American Samoa\" or whichever country sorts earliest every time. That's not a useful default for anyone.",[11,6169,6170,6171,336],{},"So Tier 3 reads from ",[17,6172,1185],{},[39,6174,6176],{"className":1888,"code":6175,"language":1890,"meta":44,"style":44},"const recents = readRecents();\nconst recentHit = recents\n  .map((iso2) => group.find((c) => c.value === iso2))\n  .find((c): c is CountryOption => Boolean(c));\nif (recentHit) return { country: recentHit, nationalNumber };\nreturn { country: group[0], nationalNumber };\n",[17,6177,6178,6192,6204,6242,6273,6285],{"__ignoreMap":44},[48,6179,6180,6182,6185,6187,6190],{"class":50,"line":51},[48,6181,329],{"class":62},[48,6183,6184],{"class":332}," recents",[48,6186,342],{"class":62},[48,6188,6189],{"class":261}," readRecents",[48,6191,3473],{"class":54},[48,6193,6194,6196,6199,6201],{"class":50,"line":81},[48,6195,329],{"class":62},[48,6197,6198],{"class":332}," recentHit",[48,6200,342],{"class":62},[48,6202,6203],{"class":54}," recents\n",[48,6205,6206,6209,6212,6214,6216,6218,6220,6223,6225,6227,6230,6232,6234,6237,6239],{"class":50,"line":87},[48,6207,6208],{"class":54},"  .",[48,6210,6211],{"class":261},"map",[48,6213,800],{"class":54},[48,6215,5333],{"class":600},[48,6217,806],{"class":54},[48,6219,647],{"class":62},[48,6221,6222],{"class":54}," group.",[48,6224,6066],{"class":261},[48,6226,800],{"class":54},[48,6228,6229],{"class":600},"c",[48,6231,806],{"class":54},[48,6233,647],{"class":62},[48,6235,6236],{"class":54}," c.value ",[48,6238,653],{"class":62},[48,6240,6241],{"class":54}," iso2))\n",[48,6243,6244,6246,6248,6250,6252,6254,6256,6259,6262,6264,6267,6270],{"class":50,"line":93},[48,6245,6208],{"class":54},[48,6247,6066],{"class":261},[48,6249,800],{"class":54},[48,6251,6229],{"class":600},[48,6253,2434],{"class":54},[48,6255,336],{"class":62},[48,6257,6258],{"class":600}," c",[48,6260,6261],{"class":62}," is",[48,6263,4651],{"class":261},[48,6265,6266],{"class":62}," =>",[48,6268,6269],{"class":261}," Boolean",[48,6271,6272],{"class":54},"(c));\n",[48,6274,6275,6277,6280,6282],{"class":50,"line":362},[48,6276,3245],{"class":62},[48,6278,6279],{"class":54}," (recentHit) ",[48,6281,2194],{"class":62},[48,6283,6284],{"class":54}," { country: recentHit, nationalNumber };\n",[48,6286,6287,6289,6291,6293],{"class":50,"line":373},[48,6288,2194],{"class":62},[48,6290,2923],{"class":54},[48,6292,2840],{"class":332},[48,6294,2928],{"class":54},[11,6296,6297,6298,6301,6302,6304],{},"The picker writes to ",[17,6299,6300],{},"ali_ui_country_recents_v1"," whenever the user explicitly picks a country, and the matcher reads it back during ambiguity. If you live in Canada and you've used the input before, the first ",[17,6303,1970],{}," you type will resolve to Canada. If you've never used it, you get the alphabetical fallback: still wrong, but only once.",[11,6306,6307],{},"A small touch, but it's the kind of thing that turns \"this technically works\" into \"this feels like it was built by someone who's used it.\"",[25,6309,6311],{"id":6310},"what-the-library-rewrite-was-really-about","What the library rewrite was really about",[11,6313,6314,6315,6318,6319,6322],{},"The original was about correctness: cursor jumps, validation reasons, bidi handling, the seven specific failure modes a phone input has to distinguish. None of that went away. All of it is still in this version: the ",[17,6316,6317],{},"dropLeadingZeros"," setter, the seven ",[17,6320,6321],{},"PhoneValidationReason"," enum values, the LTR-wrapper-over-RTL-input bidi pattern. Those things weren't wrong; they were the foundation.",[11,6324,6325,6326,6329],{},"What the library rewrite added was everything that's specific to ",[2087,6327,6328],{},"not being one app",". An ISO2\u002Fdial split that survives ambiguous dial codes. An environment-detection chain that's both pluggable and offline-safe. A picker that's a popover on a laptop and a drawer on a phone, with separate class hooks for each. Fourteen slots so the consumer's design system isn't fighting the component's. A synchronous dial table because library consumers can't wait for the network. Recents as a tiebreaker because alphabetical-first is what \"lazy\" looks like.",[11,6331,6332],{},"The line count roughly doubled. The original was 160 lines of component plus 540 of composable; the new one is several hundred lines across two components and two composables. The surface area more than doubled. And the no-config call still fits in three lines:",[39,6334,6336],{"className":41,"code":6335,"language":43,"meta":44,"style":44},"\u003CATellInput v-model:phone=\"phone\" v-model:country=\"country\" show-validation \u002F>\n",[17,6337,6338],{"__ignoreMap":44},[48,6339,6340,6342,6344,6346,6348,6350,6352,6354,6356,6358,6360,6362,6364,6366,6368,6370,6372,6374],{"class":50,"line":51},[48,6341,55],{"class":54},[48,6343,1864],{"class":58},[48,6345,4276],{"class":261},[48,6347,336],{"class":54},[48,6349,4177],{"class":261},[48,6351,66],{"class":54},[48,6353,70],{"class":69},[48,6355,4177],{"class":54},[48,6357,70],{"class":69},[48,6359,4276],{"class":261},[48,6361,336],{"class":54},[48,6363,1953],{"class":261},[48,6365,66],{"class":54},[48,6367,70],{"class":69},[48,6369,1953],{"class":54},[48,6371,70],{"class":69},[48,6373,5218],{"class":261},[48,6375,884],{"class":54},[11,6377,6378],{},"Every new prop has a default that mirrors what the in-app version did by accident. Consumers who want \"just a phone input\" get one. Consumers who want \"phone input but with our flags, our country list, our search predicate, our error messages\" get all the hooks they need.",[11,6380,6381,6382,6385,6386,6389],{},"The lesson I'd carry into the next one: making a component publishable is mostly about identifying the opinions you baked in for yourself and turning them into seams. Every ",[17,6383,6384],{},"restcountries.com"," hardcoded, every Saudi-and-Egypt fallback, every always-visible dropdown: those weren't wrong ",[2087,6387,6388],{},"for the app",", but they were the things that wouldn't survive the next consumer. Find them, name them, replace each one with a default plus an override. The default keeps your old consumer happy; the override lets the new one in.",[218,6391,6392],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s7hpK, html code.shiki .s7hpK{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic}html pre.shiki code .snhLl, html code.shiki .snhLl{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}",{"title":44,"searchDepth":81,"depth":81,"links":6394},[6395,6396,6397,6398,6399,6400,6401,6402],{"id":1881,"depth":81,"text":1882},{"id":2282,"depth":81,"text":2283},{"id":3313,"depth":81,"text":3314},{"id":4251,"depth":81,"text":4252},{"id":4397,"depth":81,"text":4398},{"id":5473,"depth":81,"text":5474},{"id":6160,"depth":81,"text":6161},{"id":6310,"depth":81,"text":6311},"2026-05-21","Turning an in-app phone input into a published Vue component forced specific changes. Detection-first UX, an ISO2 and dial-number split, a responsive popover and drawer picker, and slots for everything.","تحويل حقل هاتف من داخل تطبيق واحد إلى مكوّن Vue مُنشَر استوجب تغييرات جوهرية. تجربة استخدام تكتشف الدولة قبل أيّ تدخّل، وفصل بين رمز ISO2 ورقم الاتصال، ومنتقي متجاوب عبر Popover وDrawer، وSlots لكل شيء.",[1839,1846,6407,6408,6409,6410,6411,6412,6413,1845],"phone input","libphonenumber-js","country detection","responsive popover","vaul-vue","defineModel","slots",{},"\u002Fwrites\u002Fatell-input-the-library-rewrite",{"title":1857,"description":6404},"writes\u002Fatell-input-the-library-rewrite","ملاحظات من إعادة كتابة ATellInput كمكتبة مُنشَرة","U2nw1NwLneEoQyPNZeM61sua6if230l-WTmnZI7oQVY",{"id":6421,"title":6422,"body":6423,"date":10297,"description":10298,"description_ar":10299,"draft":1835,"extension":1836,"image":1837,"keywords":10300,"lang":1848,"meta":10307,"navigation":323,"path":10308,"seo":10309,"stem":10310,"title_ar":10311,"updatedAt":10297,"__hash__":10312},"writes\u002Fwrites\u002Fanatomy-of-adatatable.md","Anatomy of ADataTable, a typed generic Vue data table",{"type":8,"value":6424,"toc":10274},[6425,6432,6439,6442,6449,6452,6555,6565,6568,6847,6850,6861,6878,6893,6900,6913,6932,6938,7084,7115,7121,7202,7227,7247,7253,7262,7279,7479,7482,7528,7531,7536,7637,7643,7665,7672,7694,7701,7709,7916,7921,8065,8079,8096,8100,8112,8115,8182,8185,8449,8452,8499,8516,8523,8534,8622,8625,8724,8727,8749,8756,8763,8834,8848,8856,8863,8983,8989,9005,9019,9032,9048,9051,9111,9114,9118,9121,9157,9164,9167,9289,9296,9299,9552,9559,9562,9573,9590,9692,9705,9715,9720,9836,9839,9845,9930,9937,9941,9944,9950,10001,10006,10009,10080,10094,10101,10174,10187,10191,10194,10259,10271],[11,6426,6427,6428,6431],{},"I've written the same data table three times. Once as a ball of JSX, once as a class-based configuration object called ",[17,6429,6430],{},"IColumn",", and once, the one that stuck, as a typed generic Vue component with a schema-shaped column type. The third time, it finally stopped being the thing I dreaded touching.",[11,6433,6434,6435,6438],{},"This post is a tour of the current shape. It lives in my UI package as ",[17,6436,6437],{},"ADataTable",", and the interesting thing about it is not any single trick. It's that the component has stopped growing. Every feature request for the last year has fit into the existing abstractions without a new prop. That's the version of \"done\" I was aiming for.",[11,6440,6441],{},"I'll walk the files in roughly the order the request flows through them, pausing on the bits worth explaining.",[25,6443,6445,6446,6448],{"id":6444},"from-icolumn-class-to-typed-interface","From ",[17,6447,6430],{}," class to typed interface",[11,6450,6451],{},"Two years ago I had a class:",[39,6453,6457],{"className":6454,"code":6455,"language":6456,"meta":44,"style":44},"language-javascript shiki shiki-themes github-light github-dark","export class IColumn {\n  constructor(options) {\n    const { title, rowKey, type = 'default', sortable = false, \u002F* ... *\u002F } = options;\n    this.title = title;\n    this.rowKey = rowKey;\n    this.sorter = sorter || ((a, b) => a[this.rowKey] > b[this.rowKey]);\n    this.headerComponent = this.processComponent(headerComponent);\n    this.rowComponent = this.processComponent(rowComponent);\n    \u002F\u002F ...\n  }\n\n  processComponent(component) {\n    if (!component) return undefined;\n    return {\n      is: component.is || '',\n      props: typeof component.props === 'function' ? component.props : () => component.props,\n      events: typeof component.events === 'function' ? component.events : () => component.events,\n    };\n  }\n}\n","javascript",[17,6458,6459,6464,6469,6474,6479,6484,6489,6494,6499,6504,6508,6512,6517,6522,6527,6532,6537,6542,6547,6551],{"__ignoreMap":44},[48,6460,6461],{"class":50,"line":51},[48,6462,6463],{},"export class IColumn {\n",[48,6465,6466],{"class":50,"line":81},[48,6467,6468],{},"  constructor(options) {\n",[48,6470,6471],{"class":50,"line":87},[48,6472,6473],{},"    const { title, rowKey, type = 'default', sortable = false, \u002F* ... *\u002F } = options;\n",[48,6475,6476],{"class":50,"line":93},[48,6477,6478],{},"    this.title = title;\n",[48,6480,6481],{"class":50,"line":362},[48,6482,6483],{},"    this.rowKey = rowKey;\n",[48,6485,6486],{"class":50,"line":373},[48,6487,6488],{},"    this.sorter = sorter || ((a, b) => a[this.rowKey] > b[this.rowKey]);\n",[48,6490,6491],{"class":50,"line":384},[48,6492,6493],{},"    this.headerComponent = this.processComponent(headerComponent);\n",[48,6495,6496],{"class":50,"line":661},[48,6497,6498],{},"    this.rowComponent = this.processComponent(rowComponent);\n",[48,6500,6501],{"class":50,"line":690},[48,6502,6503],{},"    \u002F\u002F ...\n",[48,6505,6506],{"class":50,"line":695},[48,6507,2590],{},[48,6509,6510],{"class":50,"line":722},[48,6511,324],{"emptyLinePlaceholder":323},[48,6513,6514],{"class":50,"line":743},[48,6515,6516],{},"  processComponent(component) {\n",[48,6518,6519],{"class":50,"line":767},[48,6520,6521],{},"    if (!component) return undefined;\n",[48,6523,6524],{"class":50,"line":778},[48,6525,6526],{},"    return {\n",[48,6528,6529],{"class":50,"line":783},[48,6530,6531],{},"      is: component.is || '',\n",[48,6533,6534],{"class":50,"line":788},[48,6535,6536],{},"      props: typeof component.props === 'function' ? component.props : () => component.props,\n",[48,6538,6539],{"class":50,"line":819},[48,6540,6541],{},"      events: typeof component.events === 'function' ? component.events : () => component.events,\n",[48,6543,6544],{"class":50,"line":828},[48,6545,6546],{},"    };\n",[48,6548,6549],{"class":50,"line":833},[48,6550,2590],{},[48,6552,6553],{"class":50,"line":843},[48,6554,1717],{},[11,6556,6557,6558,6561,6562,526],{},"The class normalized props and events. If you passed a static object, it got wrapped in ",[17,6559,6560],{},"() => object"," so the table could always call it as a function. It was tidy on paper, and fine in JavaScript. The trouble started when TypeScript came in: generic row types didn't flow through a runtime class cleanly, and consumers lost type inference at exactly the spot where they were trying to reach into ",[17,6563,6564],{},"row.user.name",[11,6566,6567],{},"The current shape is a plain typed interface:",[39,6569,6571],{"className":1888,"code":6570,"language":1890,"meta":44,"style":44},"export interface IDataTableColumn\u003CT = unknown> {\n  title: string | IDataTableRowFunction\u003CT>;\n  key: string | IDataTableRowFunction\u003CT>;\n  props?: Record\u003Cstring, unknown> | IDataTableRowFunction\u003CT>;\n  type?: 'default' | 'actions' | 'selection';\n  disabled?: boolean | IDataTableRowFunction\u003CT>;\n\n  headRender?: () => Array\u003CIDataTableVNodeChild> | IDataTableVNodeChild;\n  bodyRender?: IDataTableRenderFunction\u003CT>;\n\n  sortable?: boolean;\n  sorter?: (row1: T, row2: T) => number;\n  sortValue?: ISortValue;\n  extras?: Record\u003Cstring, unknown> | IDataTableRowFunction\u003CT>;\n}\n",[17,6572,6573,6594,6615,6634,6664,6686,6705,6709,6737,6753,6757,6768,6802,6814,6843],{"__ignoreMap":44},[48,6574,6575,6577,6580,6583,6585,6588,6590,6592],{"class":50,"line":51},[48,6576,3337],{"class":62},[48,6578,6579],{"class":62}," interface",[48,6581,6582],{"class":261}," IDataTableColumn",[48,6584,55],{"class":54},[48,6586,6587],{"class":261},"T",[48,6589,342],{"class":62},[48,6591,4432],{"class":332},[48,6593,3373],{"class":54},[48,6595,6596,6599,6601,6603,6605,6608,6610,6612],{"class":50,"line":81},[48,6597,6598],{"class":600},"  title",[48,6600,336],{"class":62},[48,6602,606],{"class":332},[48,6604,678],{"class":62},[48,6606,6607],{"class":261}," IDataTableRowFunction",[48,6609,55],{"class":54},[48,6611,6587],{"class":261},[48,6613,6614],{"class":54},">;\n",[48,6616,6617,6620,6622,6624,6626,6628,6630,6632],{"class":50,"line":87},[48,6618,6619],{"class":600},"  key",[48,6621,336],{"class":62},[48,6623,606],{"class":332},[48,6625,678],{"class":62},[48,6627,6607],{"class":261},[48,6629,55],{"class":54},[48,6631,6587],{"class":261},[48,6633,6614],{"class":54},[48,6635,6636,6639,6641,6643,6645,6647,6649,6652,6654,6656,6658,6660,6662],{"class":50,"line":93},[48,6637,6638],{"class":600},"  props",[48,6640,3940],{"class":62},[48,6642,5444],{"class":261},[48,6644,55],{"class":54},[48,6646,1909],{"class":332},[48,6648,158],{"class":54},[48,6650,6651],{"class":332},"unknown",[48,6653,5531],{"class":54},[48,6655,5422],{"class":62},[48,6657,6607],{"class":261},[48,6659,55],{"class":54},[48,6661,6587],{"class":261},[48,6663,6614],{"class":54},[48,6665,6666,6669,6671,6674,6676,6679,6681,6684],{"class":50,"line":362},[48,6667,6668],{"class":600},"  type",[48,6670,3940],{"class":62},[48,6672,6673],{"class":69}," 'default'",[48,6675,678],{"class":62},[48,6677,6678],{"class":69}," 'actions'",[48,6680,678],{"class":62},[48,6682,6683],{"class":69}," 'selection'",[48,6685,318],{"class":54},[48,6687,6688,6691,6693,6695,6697,6699,6701,6703],{"class":50,"line":373},[48,6689,6690],{"class":600},"  disabled",[48,6692,3940],{"class":62},[48,6694,4664],{"class":332},[48,6696,678],{"class":62},[48,6698,6607],{"class":261},[48,6700,55],{"class":54},[48,6702,6587],{"class":261},[48,6704,6614],{"class":54},[48,6706,6707],{"class":50,"line":384},[48,6708,324],{"emptyLinePlaceholder":323},[48,6710,6711,6714,6716,6718,6720,6723,6725,6728,6730,6732,6735],{"class":50,"line":661},[48,6712,6713],{"class":261},"  headRender",[48,6715,3940],{"class":62},[48,6717,4427],{"class":54},[48,6719,647],{"class":62},[48,6721,6722],{"class":261}," Array",[48,6724,55],{"class":54},[48,6726,6727],{"class":261},"IDataTableVNodeChild",[48,6729,5531],{"class":54},[48,6731,5422],{"class":62},[48,6733,6734],{"class":261}," IDataTableVNodeChild",[48,6736,318],{"class":54},[48,6738,6739,6742,6744,6747,6749,6751],{"class":50,"line":690},[48,6740,6741],{"class":600},"  bodyRender",[48,6743,3940],{"class":62},[48,6745,6746],{"class":261}," IDataTableRenderFunction",[48,6748,55],{"class":54},[48,6750,6587],{"class":261},[48,6752,6614],{"class":54},[48,6754,6755],{"class":50,"line":695},[48,6756,324],{"emptyLinePlaceholder":323},[48,6758,6759,6762,6764,6766],{"class":50,"line":722},[48,6760,6761],{"class":600},"  sortable",[48,6763,3940],{"class":62},[48,6765,4664],{"class":332},[48,6767,318],{"class":54},[48,6769,6770,6773,6775,6777,6780,6782,6785,6787,6790,6792,6794,6796,6798,6800],{"class":50,"line":743},[48,6771,6772],{"class":261},"  sorter",[48,6774,3940],{"class":62},[48,6776,2180],{"class":54},[48,6778,6779],{"class":600},"row1",[48,6781,336],{"class":62},[48,6783,6784],{"class":261}," T",[48,6786,158],{"class":54},[48,6788,6789],{"class":600},"row2",[48,6791,336],{"class":62},[48,6793,6784],{"class":261},[48,6795,806],{"class":54},[48,6797,647],{"class":62},[48,6799,3769],{"class":332},[48,6801,318],{"class":54},[48,6803,6804,6807,6809,6812],{"class":50,"line":767},[48,6805,6806],{"class":600},"  sortValue",[48,6808,3940],{"class":62},[48,6810,6811],{"class":261}," ISortValue",[48,6813,318],{"class":54},[48,6815,6816,6819,6821,6823,6825,6827,6829,6831,6833,6835,6837,6839,6841],{"class":50,"line":778},[48,6817,6818],{"class":600},"  extras",[48,6820,3940],{"class":62},[48,6822,5444],{"class":261},[48,6824,55],{"class":54},[48,6826,1909],{"class":332},[48,6828,158],{"class":54},[48,6830,6651],{"class":332},[48,6832,5531],{"class":54},[48,6834,5422],{"class":62},[48,6836,6607],{"class":261},[48,6838,55],{"class":54},[48,6840,6587],{"class":261},[48,6842,6614],{"class":54},[48,6844,6845],{"class":50,"line":783},[48,6846,1717],{"class":54},[11,6848,6849],{},"Three things changed, and all three mattered.",[11,6851,6852,6853,6856,6857,6860],{},"First, no class. A column is just a plain object. TypeScript narrows it, editors autocomplete it, it serializes cleanly if you ever want to drive the table from JSON. The normalization the class used to do at construction time moved to a tiny helper in the renderer (",[17,6854,6855],{},"handleProps",", which we'll hit in a minute). The cost is one branch per cell render instead of one branch per column ",[2087,6858,6859],{},"definition",", and the benefit is the column stays a value, not a runtime object.",[11,6862,6863,6864,4074,6866,6869,6870,6873,6874,6877],{},"Second, the column is generic over ",[17,6865,6587],{},[17,6867,6868],{},"IDataTableColumn\u003CUser>"," means ",[17,6871,6872],{},"key"," typed as ",[17,6875,6876],{},"string | (row: User) => ..."," which flows into every downstream callback. The day I added this, three call-sites at work silently picked up type errors for columns that had been quietly broken.",[11,6879,6880,6881,6884,6885,6888,6889,6892],{},"Third, render responsibilities split cleanly. ",[17,6882,6883],{},"headRender"," is for the header cell only; ",[17,6886,6887],{},"bodyRender"," for the body cell. The class version had a single ",[17,6890,6891],{},"rowComponent"," that did both and branched internally, which meant \"custom header, default body\" required an undocumented combination of nullish fields.",[11,6894,6895,6896,6899],{},"None of this is a new idea. The move from runtime classes to schema-shaped types is the story of most UI codebases in the last five years. The only reason it's worth mentioning is that I wrote the class version ",[2087,6897,6898],{},"first"," and kept shipping it long after the typed version would have been cheaper. The cost of switching wasn't the refactor; it was admitting that the first design had run out.",[25,6901,6903,6904,158,6907,158,6910],{"id":6902},"the-render-trio-athead-atbody-atcell","The render trio: ",[17,6905,6906],{},"ATHead",[17,6908,6909],{},"ATBody",[17,6911,6912],{},"ATCell",[11,6914,6915,6916,6919,6920,6923,6924,6927,6928,6931],{},"The table component does not render rows directly. It renders a ",[17,6917,6918],{},"\u003Cthead>",", a ",[17,6921,6922],{},"\u003Ctbody>",", and those render ",[17,6925,6926],{},"\u003Ctr>"," elements that render ",[17,6929,6930],{},"\u003CATCell>"," components. The split is tight enough that each file has one job.",[11,6933,6934,6937],{},[17,6935,6936],{},"ATHead.vue"," is the simplest:",[39,6939,6941],{"className":41,"code":6940,"language":43,"meta":44,"style":44},"\u003Ctemplate>\n  \u003Cthead>\n    \u003Ctr>\n      \u003CATCell\n        v-for=\"(column, collIndex) in props.columns\"\n        :key=\"collIndex\"\n        :binder=\"column.props\"\n        :cell=\"column\"\n        :head=\"true\"\n        :record=\"{} as S\"\n        :row-index=\"collIndex\"\n        @update-sorter=\"emit('updateSorter', collIndex)\"\n      \u002F>\n    \u003C\u002Ftr>\n  \u003C\u002Fthead>\n\u003C\u002Ftemplate>\n",[17,6942,6943,6951,6959,6967,6975,6985,6995,7005,7015,7025,7035,7044,7054,7059,7068,7076],{"__ignoreMap":44},[48,6944,6945,6947,6949],{"class":50,"line":51},[48,6946,55],{"class":54},[48,6948,838],{"class":58},[48,6950,78],{"class":54},[48,6952,6953,6955,6957],{"class":50,"line":81},[48,6954,846],{"class":54},[48,6956,1517],{"class":58},[48,6958,78],{"class":54},[48,6960,6961,6963,6965],{"class":50,"line":87},[48,6962,863],{"class":54},[48,6964,1520],{"class":58},[48,6966,78],{"class":54},[48,6968,6969,6972],{"class":50,"line":93},[48,6970,6971],{"class":54},"      \u003C",[48,6973,6974],{"class":58},"ATCell\n",[48,6976,6977,6980,6982],{"class":50,"line":362},[48,6978,6979],{"class":261},"        v-for",[48,6981,66],{"class":54},[48,6983,6984],{"class":69},"\"(column, collIndex) in props.columns\"\n",[48,6986,6987,6990,6992],{"class":50,"line":373},[48,6988,6989],{"class":261},"        :key",[48,6991,66],{"class":54},[48,6993,6994],{"class":69},"\"collIndex\"\n",[48,6996,6997,7000,7002],{"class":50,"line":384},[48,6998,6999],{"class":261},"        :binder",[48,7001,66],{"class":54},[48,7003,7004],{"class":69},"\"column.props\"\n",[48,7006,7007,7010,7012],{"class":50,"line":661},[48,7008,7009],{"class":261},"        :cell",[48,7011,66],{"class":54},[48,7013,7014],{"class":69},"\"column\"\n",[48,7016,7017,7020,7022],{"class":50,"line":690},[48,7018,7019],{"class":261},"        :head",[48,7021,66],{"class":54},[48,7023,7024],{"class":69},"\"true\"\n",[48,7026,7027,7030,7032],{"class":50,"line":695},[48,7028,7029],{"class":261},"        :record",[48,7031,66],{"class":54},[48,7033,7034],{"class":69},"\"{} as S\"\n",[48,7036,7037,7040,7042],{"class":50,"line":722},[48,7038,7039],{"class":261},"        :row-index",[48,7041,66],{"class":54},[48,7043,6994],{"class":69},[48,7045,7046,7049,7051],{"class":50,"line":743},[48,7047,7048],{"class":261},"        @update-sorter",[48,7050,66],{"class":54},[48,7052,7053],{"class":69},"\"emit('updateSorter', collIndex)\"\n",[48,7055,7056],{"class":50,"line":767},[48,7057,7058],{"class":54},"      \u002F>\n",[48,7060,7061,7064,7066],{"class":50,"line":778},[48,7062,7063],{"class":54},"    \u003C\u002F",[48,7065,1520],{"class":58},[48,7067,78],{"class":54},[48,7069,7070,7072,7074],{"class":50,"line":783},[48,7071,929],{"class":54},[48,7073,1517],{"class":58},[48,7075,78],{"class":54},[48,7077,7078,7080,7082],{"class":50,"line":788},[48,7079,96],{"class":54},[48,7081,838],{"class":58},[48,7083,78],{"class":54},[11,7085,7086,7087,7089,7090,7092,7093,3017,7096,7099,7100,7103,7104,7106,7107,7110,7111,7114],{},"One ",[17,7088,6926],{},". One ",[17,7091,6930],{}," per column, with ",[17,7094,7095],{},"head={true}",[17,7097,7098],{},"record"," is a synthetic empty object cast to ",[17,7101,7102],{},"S",". The header doesn't have a row, but ",[17,7105,6912],{}," is generic over it, so something has to fill the slot. Casting ",[17,7108,7109],{},"{} as S"," is the least-bad option; giving it an actual ",[17,7112,7113],{},"undefined"," would force every cell render to null-check.",[11,7116,7117,7120],{},[17,7118,7119],{},"ATBody.vue"," is the same shape, one level up:",[39,7122,7124],{"className":41,"code":7123,"language":43,"meta":44,"style":44},"\u003Ctbody>\n  \u003Ctr v-for=\"(dataItem, $index) in props.items\" :key=\"$index + '_dataItem'\">\n    \u003CATCell\n      v-for=\"(column, collIndex) in props.columns\"\n      :key=\"collIndex + '_dataItem_' + $index\"\n      :record=\"dataItem\"\n      :head=\"false\"\n      :row-index=\"$index\"\n      :cell=\"column\"\n      :binder=\"column.props\"\n    \u002F>\n  \u003C\u002Ftr>\n  \u003Cslot \u002F>\n\u003C\u002Ftbody>\n",[17,7125,7126,7134,7139,7144,7149,7154,7159,7164,7169,7174,7179,7184,7189,7194],{"__ignoreMap":44},[48,7127,7128,7130,7132],{"class":50,"line":51},[48,7129,55],{"class":54},[48,7131,1530],{"class":58},[48,7133,78],{"class":54},[48,7135,7136],{"class":50,"line":81},[48,7137,7138],{"class":54},"  \u003Ctr v-for=\"(dataItem, $index) in props.items\" :key=\"$index + '_dataItem'\">\n",[48,7140,7141],{"class":50,"line":87},[48,7142,7143],{"class":54},"    \u003CATCell\n",[48,7145,7146],{"class":50,"line":93},[48,7147,7148],{"class":54},"      v-for=\"(column, collIndex) in props.columns\"\n",[48,7150,7151],{"class":50,"line":362},[48,7152,7153],{"class":54},"      :key=\"collIndex + '_dataItem_' + $index\"\n",[48,7155,7156],{"class":50,"line":373},[48,7157,7158],{"class":54},"      :record=\"dataItem\"\n",[48,7160,7161],{"class":50,"line":384},[48,7162,7163],{"class":54},"      :head=\"false\"\n",[48,7165,7166],{"class":50,"line":661},[48,7167,7168],{"class":54},"      :row-index=\"$index\"\n",[48,7170,7171],{"class":50,"line":690},[48,7172,7173],{"class":54},"      :cell=\"column\"\n",[48,7175,7176],{"class":50,"line":695},[48,7177,7178],{"class":54},"      :binder=\"column.props\"\n",[48,7180,7181],{"class":50,"line":722},[48,7182,7183],{"class":54},"    \u002F>\n",[48,7185,7186],{"class":50,"line":743},[48,7187,7188],{"class":54},"  \u003C\u002Ftr>\n",[48,7190,7191],{"class":50,"line":767},[48,7192,7193],{"class":54},"  \u003Cslot \u002F>\n",[48,7195,7196,7198,7200],{"class":50,"line":778},[48,7197,96],{"class":54},[48,7199,1530],{"class":58},[48,7201,78],{"class":54},[11,7203,7204,7205,158,7208,7211,7212,7215,7216,7219,7220,7223,7224,7226],{},"The composed keys (",[17,7206,7207],{},"$index + '_dataItem'",[17,7209,7210],{},"collIndex + '_dataItem_' + $index",") aren't paranoia. Vue's reconciler only cares about keys being unique ",[2087,7213,7214],{},"within their parent list",", so the ",[17,7217,7218],{},"$index"," alone would be enough. But when I'm scrolling through Vue DevTools trying to figure out which row is which, having the key say \"",[17,7221,7222],{},"3_dataItem","\" instead of just \"",[17,7225,2798],{},"\" saves me a second. That's the only reason for the suffix.",[11,7228,2251,7229,7232,7233,7235,7236,7239,7240,7243,7244,7246],{},[17,7230,7231],{},"\u003Cslot \u002F>"," inside ",[17,7234,6922],{}," is where the no-data row lands. The outer component pipes ",[17,7237,7238],{},"\u003Ctr>\u003Ctd>...\u003C\u002Ftd>\u003C\u002Ftr>"," through it. Keeping the empty-state as a slotted row rather than a separate element means ",[17,7241,7242],{},"colspan"," math works against the same ",[17,7245,6926],{}," grid, and the border-collapsing styles don't break.",[11,7248,7249,7250,526],{},"The real work happens in ",[17,7251,7252],{},"ATCell.vue",[25,7254,7256,7258,7259],{"id":7255},"atcell-one-component-two-shapes-built-via-h",[17,7257,6912],{},": one component, two shapes, built via ",[17,7260,7261],{},"h()",[11,7263,7264,7266,7267,7270,7271,7274,7275,7278],{},[17,7265,6912],{}," renders either a ",[17,7268,7269],{},"\u003Cth>"," or a ",[17,7272,7273],{},"\u003Ctd>",", depending on ",[17,7276,7277],{},"head",". Rather than writing two templates, it builds the vnode dynamically:",[39,7280,7282],{"className":1888,"code":7281,"language":1890,"meta":44,"style":44},"const Cell = computed(() => {\n  return h(\n    props.head ? 'th' : 'td',\n    {\n      class: 'p-[16px] text-md gap-2 relative',\n      colspan: '1',\n      rowspan: '1',\n      ...handleProps\u003CT>(props.cell.props, props.record),\n      ...handleProps\u003CT>(props.binder, props.record),\n    },\n    h('div', { class: 'flex items-center gap-2 w-full' },\n      props.head\n        ? props.cell.sortable\n          ? [children(props.cell, props.record), sortButton(props.cell.sortValue?.applied)]\n          : children(props.cell, props.record)\n        : children(props.cell, props.record)\n    )\n  );\n});\n",[17,7283,7284,7301,7310,7327,7332,7342,7352,7361,7375,7388,7393,7412,7417,7425,7445,7456,7465,7470,7475],{"__ignoreMap":44},[48,7285,7286,7288,7291,7293,7295,7297,7299],{"class":50,"line":51},[48,7287,329],{"class":62},[48,7289,7290],{"class":332}," Cell",[48,7292,342],{"class":62},[48,7294,641],{"class":261},[48,7296,644],{"class":54},[48,7298,647],{"class":62},[48,7300,1623],{"class":54},[48,7302,7303,7305,7308],{"class":50,"line":81},[48,7304,2963],{"class":62},[48,7306,7307],{"class":261}," h",[48,7309,2111],{"class":54},[48,7311,7312,7315,7317,7320,7322,7325],{"class":50,"line":87},[48,7313,7314],{"class":54},"    props.head ",[48,7316,757],{"class":62},[48,7318,7319],{"class":69}," 'th'",[48,7321,258],{"class":62},[48,7323,7324],{"class":69}," 'td'",[48,7326,359],{"class":54},[48,7328,7329],{"class":50,"line":93},[48,7330,7331],{"class":54},"    {\n",[48,7333,7334,7337,7340],{"class":50,"line":362},[48,7335,7336],{"class":54},"      class: ",[48,7338,7339],{"class":69},"'p-[16px] text-md gap-2 relative'",[48,7341,359],{"class":54},[48,7343,7344,7347,7350],{"class":50,"line":373},[48,7345,7346],{"class":54},"      colspan: ",[48,7348,7349],{"class":69},"'1'",[48,7351,359],{"class":54},[48,7353,7354,7357,7359],{"class":50,"line":384},[48,7355,7356],{"class":54},"      rowspan: ",[48,7358,7349],{"class":69},[48,7360,359],{"class":54},[48,7362,7363,7366,7368,7370,7372],{"class":50,"line":661},[48,7364,7365],{"class":62},"      ...",[48,7367,6855],{"class":261},[48,7369,55],{"class":54},[48,7371,6587],{"class":261},[48,7373,7374],{"class":54},">(props.cell.props, props.record),\n",[48,7376,7377,7379,7381,7383,7385],{"class":50,"line":690},[48,7378,7365],{"class":62},[48,7380,6855],{"class":261},[48,7382,55],{"class":54},[48,7384,6587],{"class":261},[48,7386,7387],{"class":54},">(props.binder, props.record),\n",[48,7389,7390],{"class":50,"line":695},[48,7391,7392],{"class":54},"    },\n",[48,7394,7395,7398,7400,7403,7406,7409],{"class":50,"line":722},[48,7396,7397],{"class":261},"    h",[48,7399,624],{"class":54},[48,7401,7402],{"class":69},"'div'",[48,7404,7405],{"class":54},", { class: ",[48,7407,7408],{"class":69},"'flex items-center gap-2 w-full'",[48,7410,7411],{"class":54}," },\n",[48,7413,7414],{"class":50,"line":743},[48,7415,7416],{"class":54},"      props.head\n",[48,7418,7419,7422],{"class":50,"line":767},[48,7420,7421],{"class":62},"        ?",[48,7423,7424],{"class":54}," props.cell.sortable\n",[48,7426,7427,7430,7433,7436,7439,7442],{"class":50,"line":778},[48,7428,7429],{"class":62},"          ?",[48,7431,7432],{"class":54}," [",[48,7434,7435],{"class":261},"children",[48,7437,7438],{"class":54},"(props.cell, props.record), ",[48,7440,7441],{"class":261},"sortButton",[48,7443,7444],{"class":54},"(props.cell.sortValue?.applied)]\n",[48,7446,7447,7450,7453],{"class":50,"line":783},[48,7448,7449],{"class":62},"          :",[48,7451,7452],{"class":261}," children",[48,7454,7455],{"class":54},"(props.cell, props.record)\n",[48,7457,7458,7461,7463],{"class":50,"line":788},[48,7459,7460],{"class":62},"        :",[48,7462,7452],{"class":261},[48,7464,7455],{"class":54},[48,7466,7467],{"class":50,"line":819},[48,7468,7469],{"class":54},"    )\n",[48,7471,7472],{"class":50,"line":828},[48,7473,7474],{"class":54},"  );\n",[48,7476,7477],{"class":50,"line":833},[48,7478,387],{"class":54},[11,7480,7481],{},"Then the template just mounts the computed vnode:",[39,7483,7485],{"className":41,"code":7484,"language":43,"meta":44,"style":44},"\u003Ctemplate>\n  \u003Ccomponent :is=\"Cell\" :key=\"props.cell.sortValue?.applied || 'ATCell'\" \u002F>\n\u003C\u002Ftemplate>\n",[17,7486,7487,7495,7520],{"__ignoreMap":44},[48,7488,7489,7491,7493],{"class":50,"line":51},[48,7490,55],{"class":54},[48,7492,838],{"class":58},[48,7494,78],{"class":54},[48,7496,7497,7499,7502,7505,7507,7510,7513,7515,7518],{"class":50,"line":81},[48,7498,846],{"class":54},[48,7500,7501],{"class":58},"component",[48,7503,7504],{"class":261}," :is",[48,7506,66],{"class":54},[48,7508,7509],{"class":69},"\"Cell\"",[48,7511,7512],{"class":261}," :key",[48,7514,66],{"class":54},[48,7516,7517],{"class":69},"\"props.cell.sortValue?.applied || 'ATCell'\"",[48,7519,884],{"class":54},[48,7521,7522,7524,7526],{"class":50,"line":87},[48,7523,96],{"class":54},[48,7525,838],{"class":58},[48,7527,78],{"class":54},[11,7529,7530],{},"Two things are doing quiet work here.",[11,7532,7533,7535],{},[17,7534,6855],{}," accepts either a plain object or a function of the row and always returns an object:",[39,7537,7539],{"className":1888,"code":7538,"language":1890,"meta":44,"style":44},"const handleProps = \u003CT,>(val: IDataTableColumn\u003CT>['props'], record: T) => {\n  if (!val) return {};\n  return typeof val === 'function' ? val(record) : val;\n};\n",[17,7540,7541,7590,7606,7633],{"__ignoreMap":44},[48,7542,7543,7545,7548,7550,7553,7555,7558,7561,7563,7565,7567,7569,7572,7575,7578,7580,7582,7584,7586,7588],{"class":50,"line":51},[48,7544,329],{"class":62},[48,7546,7547],{"class":261}," handleProps",[48,7549,342],{"class":62},[48,7551,7552],{"class":54}," \u003C",[48,7554,6587],{"class":261},[48,7556,7557],{"class":54},",>(",[48,7559,7560],{"class":600},"val",[48,7562,336],{"class":62},[48,7564,6582],{"class":261},[48,7566,55],{"class":54},[48,7568,6587],{"class":261},[48,7570,7571],{"class":54},">[",[48,7573,7574],{"class":69},"'props'",[48,7576,7577],{"class":54},"], ",[48,7579,7098],{"class":600},[48,7581,336],{"class":62},[48,7583,6784],{"class":261},[48,7585,806],{"class":54},[48,7587,647],{"class":62},[48,7589,1623],{"class":54},[48,7591,7592,7594,7596,7598,7601,7603],{"class":50,"line":81},[48,7593,2450],{"class":62},[48,7595,2180],{"class":54},[48,7597,2455],{"class":62},[48,7599,7600],{"class":54},"val) ",[48,7602,2194],{"class":62},[48,7604,7605],{"class":54}," {};\n",[48,7607,7608,7610,7612,7615,7617,7619,7622,7625,7628,7630],{"class":50,"line":87},[48,7609,2963],{"class":62},[48,7611,3803],{"class":62},[48,7613,7614],{"class":54}," val ",[48,7616,653],{"class":62},[48,7618,3812],{"class":69},[48,7620,7621],{"class":62}," ?",[48,7623,7624],{"class":261}," val",[48,7626,7627],{"class":54},"(record) ",[48,7629,336],{"class":62},[48,7631,7632],{"class":54}," val;\n",[48,7634,7635],{"class":50,"line":93},[48,7636,5737],{"class":54},[11,7638,7639,7640,7642],{},"That's the \"always callable\" contract the old ",[17,7641,6430],{}," class enforced at construction, moved to the render path, where it's cheaper in the common case (static props don't get wrapped unnecessarily) and still gives consumers the choice.",[11,7644,7645,7646,7649,7650,7653,7654,7657,7658,7661,7662,7664],{},"The spread order matters: ",[17,7647,7648],{},"props.cell.props"," is spread first, ",[17,7651,7652],{},"props.binder"," second. ",[17,7655,7656],{},"binder"," is the same as ",[17,7659,7660],{},"cell.props","; it's passed in via a separate prop for historical reasons I'd collapse if I rewrote this. Until then, ",[17,7663,7656],{}," wins on conflict, which is what callers expect.",[1012,7666,2251,7668,7671],{"id":7667},"the-keysortvalueapplied-that-looks-like-a-typo",[17,7669,7670],{},":key=\"sortValue?.applied\""," that looks like a typo",[11,7673,7674,7675,7678,7679,7682,7683,7686,7687,7689,7690,7693],{},"The second thing is that odd ",[17,7676,7677],{},":key=\"props.cell.sortValue?.applied || 'ATCell'\"",". The component's ",[17,7680,7681],{},"\u003Ccomponent :is>"," reacts to changes in the vnode, but the ",[17,7684,7685],{},":key"," looks like it's there to force a remount when the sort state flips. That's exactly what it's for. Vue's patching is smart enough to diff two ",[17,7688,7261],{}," outputs, but if the cell's props came from a function of the record ",[2087,7691,7692],{},"and"," the record changed at the same time the sort flipped, the patcher can sometimes hold onto a stale attribute. Keying by sort state forces a clean unmount\u002Fremount on exactly the transition where that can matter. It's one line. Removing it hasn't bitten me yet, which either means it's over-cautious or it's quietly preventing the bug. I'm not sure, and I've stopped trying to prove it either way. The cost of the key is a single render per sort toggle.",[25,7695,7697,7700],{"id":7696},"handlevaluebasedonkey-dot-paths-into-rows",[17,7698,7699],{},"handleValueBasedOnKey",": dot-paths into rows",[11,7702,7703,7704,3722,7706,7708],{},"When a column doesn't supply ",[17,7705,6883],{},[17,7707,6887],{},", the cell falls back to a default renderer:",[39,7710,7712],{"className":1888,"code":7711,"language":1890,"meta":44,"style":44},"const children = (cell: IDataTableColumn\u003CT>, record: T) => {\n  const defaultHeadCell = h('span', {\n    class: 'text-[16px] text-secondary font-medium capitalize',\n    innerHTML: typeof cell.title === 'function' ? cell.title(record) : cell.title,\n  });\n  const defaultBodyCell = h('span', {\n    class: 'text-[16px] text-foreground capitalize',\n    innerHTML: handleValueBasedOnKey(cell.key as string, record),\n  });\n  if (props.head) {\n    return cell.headRender ? cell.headRender() : defaultHeadCell;\n  }\n  return cell.bodyRender ? cell.bodyRender(record, props.rowIndex) : defaultBodyCell;\n};\n",[17,7713,7714,7750,7769,7779,7809,7814,7831,7840,7856,7860,7867,7887,7891,7912],{"__ignoreMap":44},[48,7715,7716,7718,7720,7722,7724,7727,7729,7731,7733,7735,7738,7740,7742,7744,7746,7748],{"class":50,"line":51},[48,7717,329],{"class":62},[48,7719,7452],{"class":261},[48,7721,342],{"class":62},[48,7723,2180],{"class":54},[48,7725,7726],{"class":600},"cell",[48,7728,336],{"class":62},[48,7730,6582],{"class":261},[48,7732,55],{"class":54},[48,7734,6587],{"class":261},[48,7736,7737],{"class":54},">, ",[48,7739,7098],{"class":600},[48,7741,336],{"class":62},[48,7743,6784],{"class":261},[48,7745,806],{"class":54},[48,7747,647],{"class":62},[48,7749,1623],{"class":54},[48,7751,7752,7754,7757,7759,7761,7763,7766],{"class":50,"line":81},[48,7753,2604],{"class":62},[48,7755,7756],{"class":332}," defaultHeadCell",[48,7758,342],{"class":62},[48,7760,7307],{"class":261},[48,7762,624],{"class":54},[48,7764,7765],{"class":69},"'span'",[48,7767,7768],{"class":54},", {\n",[48,7770,7771,7774,7777],{"class":50,"line":87},[48,7772,7773],{"class":54},"    class: ",[48,7775,7776],{"class":69},"'text-[16px] text-secondary font-medium capitalize'",[48,7778,359],{"class":54},[48,7780,7781,7784,7787,7790,7792,7794,7796,7799,7802,7804,7806],{"class":50,"line":93},[48,7782,7783],{"class":54},"    innerHTML: ",[48,7785,7786],{"class":62},"typeof",[48,7788,7789],{"class":54}," cell.title ",[48,7791,653],{"class":62},[48,7793,3812],{"class":69},[48,7795,7621],{"class":62},[48,7797,7798],{"class":54}," cell.",[48,7800,7801],{"class":261},"title",[48,7803,7627],{"class":54},[48,7805,336],{"class":62},[48,7807,7808],{"class":54}," cell.title,\n",[48,7810,7811],{"class":50,"line":362},[48,7812,7813],{"class":54},"  });\n",[48,7815,7816,7818,7821,7823,7825,7827,7829],{"class":50,"line":373},[48,7817,2604],{"class":62},[48,7819,7820],{"class":332}," defaultBodyCell",[48,7822,342],{"class":62},[48,7824,7307],{"class":261},[48,7826,624],{"class":54},[48,7828,7765],{"class":69},[48,7830,7768],{"class":54},[48,7832,7833,7835,7838],{"class":50,"line":384},[48,7834,7773],{"class":54},[48,7836,7837],{"class":69},"'text-[16px] text-foreground capitalize'",[48,7839,359],{"class":54},[48,7841,7842,7844,7846,7849,7851,7853],{"class":50,"line":661},[48,7843,7783],{"class":54},[48,7845,7699],{"class":261},[48,7847,7848],{"class":54},"(cell.key ",[48,7850,2658],{"class":62},[48,7852,606],{"class":332},[48,7854,7855],{"class":54},", record),\n",[48,7857,7858],{"class":50,"line":690},[48,7859,7813],{"class":54},[48,7861,7862,7864],{"class":50,"line":695},[48,7863,2450],{"class":62},[48,7865,7866],{"class":54}," (props.head) {\n",[48,7868,7869,7871,7874,7876,7878,7880,7882,7884],{"class":50,"line":722},[48,7870,2946],{"class":62},[48,7872,7873],{"class":54}," cell.headRender ",[48,7875,757],{"class":62},[48,7877,7798],{"class":54},[48,7879,6883],{"class":261},[48,7881,3611],{"class":54},[48,7883,336],{"class":62},[48,7885,7886],{"class":54}," defaultHeadCell;\n",[48,7888,7889],{"class":50,"line":743},[48,7890,2590],{"class":54},[48,7892,7893,7895,7898,7900,7902,7904,7907,7909],{"class":50,"line":767},[48,7894,2963],{"class":62},[48,7896,7897],{"class":54}," cell.bodyRender ",[48,7899,757],{"class":62},[48,7901,7798],{"class":54},[48,7903,6887],{"class":261},[48,7905,7906],{"class":54},"(record, props.rowIndex) ",[48,7908,336],{"class":62},[48,7910,7911],{"class":54}," defaultBodyCell;\n",[48,7913,7914],{"class":50,"line":778},[48,7915,5737],{"class":54},[11,7917,7918,7919,336],{},"The piece worth zooming in on is ",[17,7920,7699],{},[39,7922,7924],{"className":1888,"code":7923,"language":1890,"meta":44,"style":44},"const handleValueBasedOnKey = (key: string, receivedData: T): unknown => {\n  return key.split('.').reduce((acc: unknown, curr) => {\n    if (acc && typeof acc === 'object') {\n      return (acc as Record\u003Cstring, unknown>)[curr];\n    }\n    return undefined;\n  }, receivedData);\n};\n",[17,7925,7926,7962,8002,8023,8044,8048,8056,8061],{"__ignoreMap":44},[48,7927,7928,7930,7933,7935,7937,7939,7941,7943,7945,7948,7950,7952,7954,7956,7958,7960],{"class":50,"line":51},[48,7929,329],{"class":62},[48,7931,7932],{"class":261}," handleValueBasedOnKey",[48,7934,342],{"class":62},[48,7936,2180],{"class":54},[48,7938,6872],{"class":600},[48,7940,336],{"class":62},[48,7942,606],{"class":332},[48,7944,158],{"class":54},[48,7946,7947],{"class":600},"receivedData",[48,7949,336],{"class":62},[48,7951,6784],{"class":261},[48,7953,2434],{"class":54},[48,7955,336],{"class":62},[48,7957,4432],{"class":332},[48,7959,6266],{"class":62},[48,7961,1623],{"class":54},[48,7963,7964,7966,7969,7972,7974,7977,7979,7982,7984,7987,7989,7991,7993,7996,7998,8000],{"class":50,"line":81},[48,7965,2963],{"class":62},[48,7967,7968],{"class":54}," key.",[48,7970,7971],{"class":261},"split",[48,7973,624],{"class":54},[48,7975,7976],{"class":69},"'.'",[48,7978,1251],{"class":54},[48,7980,7981],{"class":261},"reduce",[48,7983,800],{"class":54},[48,7985,7986],{"class":600},"acc",[48,7988,336],{"class":62},[48,7990,4432],{"class":332},[48,7992,158],{"class":54},[48,7994,7995],{"class":600},"curr",[48,7997,806],{"class":54},[48,7999,647],{"class":62},[48,8001,1623],{"class":54},[48,8003,8004,8006,8009,8011,8013,8016,8018,8021],{"class":50,"line":87},[48,8005,2135],{"class":62},[48,8007,8008],{"class":54}," (acc ",[48,8010,2514],{"class":62},[48,8012,3803],{"class":62},[48,8014,8015],{"class":54}," acc ",[48,8017,653],{"class":62},[48,8019,8020],{"class":69}," 'object'",[48,8022,2146],{"class":54},[48,8024,8025,8027,8029,8031,8033,8035,8037,8039,8041],{"class":50,"line":93},[48,8026,2166],{"class":62},[48,8028,8008],{"class":54},[48,8030,2658],{"class":62},[48,8032,5444],{"class":261},[48,8034,55],{"class":54},[48,8036,1909],{"class":332},[48,8038,158],{"class":54},[48,8040,6651],{"class":332},[48,8042,8043],{"class":54},">)[curr];\n",[48,8045,8046],{"class":50,"line":362},[48,8047,2173],{"class":54},[48,8049,8050,8052,8054],{"class":50,"line":373},[48,8051,2946],{"class":62},[48,8053,5429],{"class":332},[48,8055,318],{"class":54},[48,8057,8058],{"class":50,"line":384},[48,8059,8060],{"class":54},"  }, receivedData);\n",[48,8062,8063],{"class":50,"line":661},[48,8064,5737],{"class":54},[11,8066,8067,8068,8071,8072,8074,8075,8078],{},"A column with ",[17,8069,8070],{},"key: 'user.profile.displayName'"," walks the dots and pulls the nested value. This is the single feature that stops consumers from writing a ",[17,8073,6887],{}," function 80% of the time. Most tables have three or four columns that are just ",[17,8076,8077],{},"row.foo.bar","; giving them a dot-path means the schema-as-data approach wins one more round.",[11,8080,8081,8082,8085,8086,8089,8090,8092,8093,8095],{},"I'm using ",[17,8083,8084],{},"innerHTML"," for the default renderer. That's deliberate for titles that want to include ",[17,8087,8088],{},"\u003Cbr>"," or a small bit of emphasis, and it's a footgun for user-controlled data. The convention I ended up with in code review is \"use the default for static titles and sanitized data; for anything user-generated, write a ",[17,8091,6887],{}," that uses a child component.\" I haven't found a cleaner way to express that in the type system, and I'm not sure I want to force everyone through ",[17,8094,6887],{}," for every cell.",[25,8097,8099],{"id":8098},"row-keys-the-fallback-when-nobody-supplies-one","Row keys: the fallback when nobody supplies one",[11,8101,8102,8103,8105,8106,8108,8109,8111],{},"The default Vue ",[17,8104,7685],{}," on rows is ",[17,8107,7218],{},", which is the wrong answer for anything that changes order. To do better you need a stable identifier per row, and since the table is generic over ",[17,8110,6587],{},", there's no field you can rely on universally.",[11,8113,8114],{},"The interface is the obvious one:",[39,8116,8118],{"className":1888,"code":8117,"language":1890,"meta":44,"style":44},"type DataTablePropsWithKey\u003CT> = IDataTableProps\u003CT> & {\n  \u002F** Optional stable key extractor; recommended *\u002F\n  rowKey?: (row: T) => string;\n};\n",[17,8119,8120,8149,8154,8178],{"__ignoreMap":44},[48,8121,8122,8124,8127,8129,8131,8133,8135,8138,8140,8142,8144,8147],{"class":50,"line":51},[48,8123,306],{"class":62},[48,8125,8126],{"class":261}," DataTablePropsWithKey",[48,8128,55],{"class":54},[48,8130,6587],{"class":261},[48,8132,5531],{"class":54},[48,8134,66],{"class":62},[48,8136,8137],{"class":261}," IDataTableProps",[48,8139,55],{"class":54},[48,8141,6587],{"class":261},[48,8143,5531],{"class":54},[48,8145,8146],{"class":62},"&",[48,8148,1623],{"class":54},[48,8150,8151],{"class":50,"line":81},[48,8152,8153],{"class":2473},"  \u002F** Optional stable key extractor; recommended *\u002F\n",[48,8155,8156,8159,8161,8163,8166,8168,8170,8172,8174,8176],{"class":50,"line":87},[48,8157,8158],{"class":261},"  rowKey",[48,8160,3940],{"class":62},[48,8162,2180],{"class":54},[48,8164,8165],{"class":600},"row",[48,8167,336],{"class":62},[48,8169,6784],{"class":261},[48,8171,806],{"class":54},[48,8173,647],{"class":62},[48,8175,606],{"class":332},[48,8177,318],{"class":54},[48,8179,8180],{"class":50,"line":93},[48,8181,5737],{"class":54},[11,8183,8184],{},"In practice, nobody passes it. The fallback had to work for arbitrary rows:",[39,8186,8188],{"className":1888,"code":8187,"language":1890,"meta":44,"style":44},"function stableKeyFromObject(obj: Record\u003Cstring, unknown>): string {\n  if ('id' in obj && typeof obj.id !== 'undefined') {\n    return String(obj.id as unknown);\n  }\n  const keys = Object.keys(obj).sort();\n  return keys\n    .map((k) => `${k}:${String(obj[k])}`)\n    .join('|');\n}\n\nfunction getRowKey(row: T): string {\n  if (typeof globalProps.rowKey === 'function') return globalProps.rowKey(row);\n  return stableKeyFromObject(row as unknown as Record\u003Cstring, unknown>);\n}\n",[17,8189,8190,8223,8252,8267,8271,8293,8300,8343,8357,8361,8365,8388,8416,8445],{"__ignoreMap":44},[48,8191,8192,8194,8197,8199,8202,8204,8206,8208,8210,8212,8214,8217,8219,8221],{"class":50,"line":51},[48,8193,2419],{"class":62},[48,8195,8196],{"class":261}," stableKeyFromObject",[48,8198,624],{"class":54},[48,8200,8201],{"class":600},"obj",[48,8203,336],{"class":62},[48,8205,5444],{"class":261},[48,8207,55],{"class":54},[48,8209,1909],{"class":332},[48,8211,158],{"class":54},[48,8213,6651],{"class":332},[48,8215,8216],{"class":54},">)",[48,8218,336],{"class":62},[48,8220,606],{"class":332},[48,8222,1623],{"class":54},[48,8224,8225,8227,8229,8232,8235,8238,8240,8242,8245,8247,8250],{"class":50,"line":81},[48,8226,2450],{"class":62},[48,8228,2180],{"class":54},[48,8230,8231],{"class":69},"'id'",[48,8233,8234],{"class":62}," in",[48,8236,8237],{"class":54}," obj ",[48,8239,2514],{"class":62},[48,8241,3803],{"class":62},[48,8243,8244],{"class":54}," obj.id ",[48,8246,3809],{"class":62},[48,8248,8249],{"class":69}," 'undefined'",[48,8251,2146],{"class":54},[48,8253,8254,8256,8258,8261,8263,8265],{"class":50,"line":87},[48,8255,2946],{"class":62},[48,8257,5792],{"class":261},[48,8259,8260],{"class":54},"(obj.id ",[48,8262,2658],{"class":62},[48,8264,4432],{"class":332},[48,8266,629],{"class":54},[48,8268,8269],{"class":50,"line":93},[48,8270,2590],{"class":54},[48,8272,8273,8275,8278,8280,8282,8285,8288,8291],{"class":50,"line":362},[48,8274,2604],{"class":62},[48,8276,8277],{"class":332}," keys",[48,8279,342],{"class":62},[48,8281,6053],{"class":54},[48,8283,8284],{"class":261},"keys",[48,8286,8287],{"class":54},"(obj).",[48,8289,8290],{"class":261},"sort",[48,8292,3473],{"class":54},[48,8294,8295,8297],{"class":50,"line":373},[48,8296,2963],{"class":62},[48,8298,8299],{"class":54}," keys\n",[48,8301,8302,8305,8307,8309,8312,8314,8316,8319,8321,8324,8326,8328,8330,8333,8335,8338,8340],{"class":50,"line":384},[48,8303,8304],{"class":54},"    .",[48,8306,6211],{"class":261},[48,8308,800],{"class":54},[48,8310,8311],{"class":600},"k",[48,8313,806],{"class":54},[48,8315,647],{"class":62},[48,8317,8318],{"class":69}," `${",[48,8320,8311],{"class":54},[48,8322,8323],{"class":69},"}:${",[48,8325,2214],{"class":261},[48,8327,624],{"class":69},[48,8329,8201],{"class":54},[48,8331,8332],{"class":69},"[",[48,8334,8311],{"class":54},[48,8336,8337],{"class":69},"])",[48,8339,738],{"class":69},[48,8341,8342],{"class":54},")\n",[48,8344,8345,8347,8350,8352,8355],{"class":50,"line":661},[48,8346,8304],{"class":54},[48,8348,8349],{"class":261},"join",[48,8351,624],{"class":54},[48,8353,8354],{"class":69},"'|'",[48,8356,629],{"class":54},[48,8358,8359],{"class":50,"line":690},[48,8360,1717],{"class":54},[48,8362,8363],{"class":50,"line":695},[48,8364,324],{"emptyLinePlaceholder":323},[48,8366,8367,8369,8372,8374,8376,8378,8380,8382,8384,8386],{"class":50,"line":722},[48,8368,2419],{"class":62},[48,8370,8371],{"class":261}," getRowKey",[48,8373,624],{"class":54},[48,8375,8165],{"class":600},[48,8377,336],{"class":62},[48,8379,6784],{"class":261},[48,8381,2434],{"class":54},[48,8383,336],{"class":62},[48,8385,606],{"class":332},[48,8387,1623],{"class":54},[48,8389,8390,8392,8394,8396,8399,8401,8403,8405,8407,8410,8413],{"class":50,"line":743},[48,8391,2450],{"class":62},[48,8393,2180],{"class":54},[48,8395,7786],{"class":62},[48,8397,8398],{"class":54}," globalProps.rowKey ",[48,8400,653],{"class":62},[48,8402,3812],{"class":69},[48,8404,806],{"class":54},[48,8406,2194],{"class":62},[48,8408,8409],{"class":54}," globalProps.",[48,8411,8412],{"class":261},"rowKey",[48,8414,8415],{"class":54},"(row);\n",[48,8417,8418,8420,8422,8425,8427,8429,8432,8434,8436,8438,8440,8442],{"class":50,"line":767},[48,8419,2963],{"class":62},[48,8421,8196],{"class":261},[48,8423,8424],{"class":54},"(row ",[48,8426,2658],{"class":62},[48,8428,4432],{"class":332},[48,8430,8431],{"class":62}," as",[48,8433,5444],{"class":261},[48,8435,55],{"class":54},[48,8437,1909],{"class":332},[48,8439,158],{"class":54},[48,8441,6651],{"class":332},[48,8443,8444],{"class":54},">);\n",[48,8446,8447],{"class":50,"line":778},[48,8448,1717],{"class":54},[11,8450,8451],{},"Three small calls, each earning its weight:",[8453,8454,8455,8468,8482],"ul",{},[3669,8456,8457,8463,8464,8467],{},[35,8458,8459,8462],{},[17,8460,8461],{},"id"," shortcut first."," Most backend records have one. ",[17,8465,8466],{},"String(obj.id)"," handles numbers, strings, and BigInts without branching. The cast is dishonest in the type system and honest at runtime.",[3669,8469,8470,8473,8474,8477,8478,8481],{},[35,8471,8472],{},"Sort the keys before hashing."," ",[17,8475,8476],{},"Object.keys()"," returns insertion order. For most objects that order is the same every time, but \"most\" isn't \"all\". An object rebuilt from ",[17,8479,8480],{},"JSON.parse"," won't necessarily agree with one built from an object literal with the same keys. Sorting kills the whole class.",[3669,8483,8484,8473,8493,8495,8496,8498],{},[35,8485,8486,8489,8490,526],{},[17,8487,8488],{},"String()"," over ",[17,8491,8492],{},"JSON.stringify",[17,8494,8488],{}," never throws; ",[17,8497,8492],{}," throws on circular references (which happen when you're careless with Pinia). The goal is a key that's stable, not a serializer.",[11,8500,8501,8502,8504,8505,8508,8509,8511,8512,8515],{},"The key is used both for Vue's reconciliation ",[2087,8503,7692],{}," for selection. That's the invariant that matters. If the two used different notions of identity (",[17,8506,8507],{},":key=\"i\""," and selection using ",[17,8510,653],{},"), a sort could drop checkboxes while keeping the data. Using ",[17,8513,8514],{},"getRowKey"," in both places means they can't drift.",[25,8517,8519,8520],{"id":8518},"selection-as-a-mapstring-t","Selection as a ",[17,8521,8522],{},"Map\u003Cstring, T>",[11,8524,8525,8526,8529,8530,8533],{},"There's no separate selection state. The component holds ",[17,8527,8528],{},"localSelectedItems"," (a ",[17,8531,8532],{},"shallowRef\u003CT[]>",") and derives a Map by key:",[39,8535,8537],{"className":1888,"code":8536,"language":1890,"meta":44,"style":44},"const hashedSelectedItems = computed(() => {\n  const map = new Map\u003Cstring, T>();\n  localSelectedItems.value.forEach((x) => map.set(getRowKey(x), x));\n  return map;\n});\n",[17,8538,8539,8556,8581,8611,8618],{"__ignoreMap":44},[48,8540,8541,8543,8546,8548,8550,8552,8554],{"class":50,"line":51},[48,8542,329],{"class":62},[48,8544,8545],{"class":332}," hashedSelectedItems",[48,8547,342],{"class":62},[48,8549,641],{"class":261},[48,8551,644],{"class":54},[48,8553,647],{"class":62},[48,8555,1623],{"class":54},[48,8557,8558,8560,8563,8565,8567,8570,8572,8574,8576,8578],{"class":50,"line":81},[48,8559,2604],{"class":62},[48,8561,8562],{"class":332}," map",[48,8564,342],{"class":62},[48,8566,3832],{"class":62},[48,8568,8569],{"class":261}," Map",[48,8571,55],{"class":54},[48,8573,1909],{"class":332},[48,8575,158],{"class":54},[48,8577,6587],{"class":261},[48,8579,8580],{"class":54},">();\n",[48,8582,8583,8586,8589,8591,8594,8596,8598,8601,8604,8606,8608],{"class":50,"line":87},[48,8584,8585],{"class":54},"  localSelectedItems.value.",[48,8587,8588],{"class":261},"forEach",[48,8590,800],{"class":54},[48,8592,8593],{"class":600},"x",[48,8595,806],{"class":54},[48,8597,647],{"class":62},[48,8599,8600],{"class":54}," map.",[48,8602,8603],{"class":261},"set",[48,8605,624],{"class":54},[48,8607,8514],{"class":261},[48,8609,8610],{"class":54},"(x), x));\n",[48,8612,8613,8615],{"class":50,"line":93},[48,8614,2963],{"class":62},[48,8616,8617],{"class":54}," map;\n",[48,8619,8620],{"class":50,"line":362},[48,8621,387],{"class":54},[11,8623,8624],{},"The selection column's header checkbox checks \"are all items selected\" with one array-scan:",[39,8626,8628],{"className":1888,"code":8627,"language":1890,"meta":44,"style":44},"const isAllDataSelected = computed(() => {\n  const atData = localItems.value.filter((item) =>\n    hashedSelectedItems.value.has(getRowKey(item))\n  );\n  return atData.length === localItems.value.length && localItems.value.length > 0;\n});\n",[17,8629,8630,8647,8671,8686,8690,8720],{"__ignoreMap":44},[48,8631,8632,8634,8637,8639,8641,8643,8645],{"class":50,"line":51},[48,8633,329],{"class":62},[48,8635,8636],{"class":332}," isAllDataSelected",[48,8638,342],{"class":62},[48,8640,641],{"class":261},[48,8642,644],{"class":54},[48,8644,647],{"class":62},[48,8646,1623],{"class":54},[48,8648,8649,8651,8654,8656,8659,8662,8664,8666,8668],{"class":50,"line":81},[48,8650,2604],{"class":62},[48,8652,8653],{"class":332}," atData",[48,8655,342],{"class":62},[48,8657,8658],{"class":54}," localItems.value.",[48,8660,8661],{"class":261},"filter",[48,8663,800],{"class":54},[48,8665,5012],{"class":600},[48,8667,806],{"class":54},[48,8669,8670],{"class":62},"=>\n",[48,8672,8673,8676,8679,8681,8683],{"class":50,"line":87},[48,8674,8675],{"class":54},"    hashedSelectedItems.value.",[48,8677,8678],{"class":261},"has",[48,8680,624],{"class":54},[48,8682,8514],{"class":261},[48,8684,8685],{"class":54},"(item))\n",[48,8687,8688],{"class":50,"line":93},[48,8689,7474],{"class":54},[48,8691,8692,8694,8697,8699,8701,8703,8705,8708,8710,8712,8715,8718],{"class":50,"line":362},[48,8693,2963],{"class":62},[48,8695,8696],{"class":54}," atData.",[48,8698,2627],{"class":332},[48,8700,2914],{"class":62},[48,8702,8658],{"class":54},[48,8704,2627],{"class":332},[48,8706,8707],{"class":62}," &&",[48,8709,8658],{"class":54},[48,8711,2627],{"class":332},[48,8713,8714],{"class":62}," >",[48,8716,8717],{"class":332}," 0",[48,8719,318],{"class":54},[48,8721,8722],{"class":50,"line":373},[48,8723,387],{"class":54},[11,8725,8726],{},"O(n) across the visible items, which is already O(n) to render. Nothing we can do to improve that. But per-row selection checks are O(1):",[39,8728,8730],{"className":1888,"code":8729,"language":1890,"meta":44,"style":44},"modelValue: hashedSelectedItems.value.has(getRowKey(rowData)),\n",[17,8731,8732],{"__ignoreMap":44},[48,8733,8734,8737,8740,8742,8744,8746],{"class":50,"line":51},[48,8735,8736],{"class":261},"modelValue",[48,8738,8739],{"class":54},": hashedSelectedItems.value.",[48,8741,8678],{"class":261},[48,8743,624],{"class":54},[48,8745,8514],{"class":261},[48,8747,8748],{"class":54},"(rowData)),\n",[11,8750,8751,8752,8755],{},"Without the computed Map, each checkbox would do ",[17,8753,8754],{},"localSelectedItems.value.find(...)"," on render. A 100-row table with 100 selected items would be doing 10,000 comparisons per re-render. The Map turns that into 100 constant-time lookups. You only notice this once you've blown up a table with real data. At which point you learn the pattern and stop writing the naïve version.",[11,8757,8758,8759,8762],{},"The selection column itself is built on demand. The consumer either declares it as a column (",[17,8760,8761],{},"type: 'selection'",") or doesn't:",[39,8764,8766],{"className":1888,"code":8765,"language":1890,"meta":44,"style":44},"if (hasSelectColumn) {\n  const selectionColumn = handleSelectionColumn();\n  _columns = _columns.filter((col) => col.type !== 'selection');\n  _columns.unshift(selectionColumn);\n}\n",[17,8767,8768,8775,8789,8819,8830],{"__ignoreMap":44},[48,8769,8770,8772],{"class":50,"line":51},[48,8771,3245],{"class":62},[48,8773,8774],{"class":54}," (hasSelectColumn) {\n",[48,8776,8777,8779,8782,8784,8787],{"class":50,"line":81},[48,8778,2604],{"class":62},[48,8780,8781],{"class":332}," selectionColumn",[48,8783,342],{"class":62},[48,8785,8786],{"class":261}," handleSelectionColumn",[48,8788,3473],{"class":54},[48,8790,8791,8794,8796,8799,8801,8803,8806,8808,8810,8813,8815,8817],{"class":50,"line":87},[48,8792,8793],{"class":54},"  _columns ",[48,8795,66],{"class":62},[48,8797,8798],{"class":54}," _columns.",[48,8800,8661],{"class":261},[48,8802,800],{"class":54},[48,8804,8805],{"class":600},"col",[48,8807,806],{"class":54},[48,8809,647],{"class":62},[48,8811,8812],{"class":54}," col.type ",[48,8814,3809],{"class":62},[48,8816,6683],{"class":69},[48,8818,629],{"class":54},[48,8820,8821,8824,8827],{"class":50,"line":93},[48,8822,8823],{"class":54},"  _columns.",[48,8825,8826],{"class":261},"unshift",[48,8828,8829],{"class":54},"(selectionColumn);\n",[48,8831,8832],{"class":50,"line":362},[48,8833,1717],{"class":54},[11,8835,8836,8837,8839,8840,8843,8844,8847],{},"The existing ",[17,8838,8761],{}," column, if any, is replaced with the internally-built one and forced to position 0. The consumer's instance is a ",[2087,8841,8842],{},"marker"," (\"I want selection in this table\"), and the component writes the actual renderers. This gives consumers the API surface of \"selection is a column\" without requiring them to know how to build checkboxes and wire up the ",[17,8845,8846],{},"indeterminate"," state.",[25,8849,8851,8852,8855],{"id":8850},"actions-and-the-markraw-that-earned-its-keep","Actions, and the ",[17,8853,8854],{},"markRaw"," that earned its keep",[11,8857,8858,8859,8862],{},"Actions work the same way: the consumer declares an actions column (or the component auto-appends one if ",[17,8860,8861],{},"actions.length > 0","), and the renderer is built internally:",[39,8864,8866],{"className":1888,"code":8865,"language":1890,"meta":44,"style":44},"const actionsComponent: IDataTableRenderFunction\u003CT> = (rowData, index) =>\n  h(markRaw(AActionGroup), {\n    actions: localActions.value as IAction\u003Cunknown>[],\n    record: rowData,\n    popover: globalProps.popover,\n    maxMainCount: getVisibleLength(rowData),\n    onUpdateAction: (action) =>\n      emit('updateAction', { action, row: rowData, rowIndex: index }),\n  });\n",[17,8867,8868,8901,8913,8930,8935,8940,8951,8966,8979],{"__ignoreMap":44},[48,8869,8870,8872,8875,8877,8879,8881,8883,8885,8887,8889,8892,8894,8897,8899],{"class":50,"line":51},[48,8871,329],{"class":62},[48,8873,8874],{"class":261}," actionsComponent",[48,8876,336],{"class":62},[48,8878,6746],{"class":261},[48,8880,55],{"class":54},[48,8882,6587],{"class":261},[48,8884,5531],{"class":54},[48,8886,66],{"class":62},[48,8888,2180],{"class":54},[48,8890,8891],{"class":600},"rowData",[48,8893,158],{"class":54},[48,8895,8896],{"class":600},"index",[48,8898,806],{"class":54},[48,8900,8670],{"class":62},[48,8902,8903,8906,8908,8910],{"class":50,"line":81},[48,8904,8905],{"class":261},"  h",[48,8907,624],{"class":54},[48,8909,8854],{"class":261},[48,8911,8912],{"class":54},"(AActionGroup), {\n",[48,8914,8915,8918,8920,8923,8925,8927],{"class":50,"line":87},[48,8916,8917],{"class":54},"    actions: localActions.value ",[48,8919,2658],{"class":62},[48,8921,8922],{"class":261}," IAction",[48,8924,55],{"class":54},[48,8926,6651],{"class":332},[48,8928,8929],{"class":54},">[],\n",[48,8931,8932],{"class":50,"line":93},[48,8933,8934],{"class":54},"    record: rowData,\n",[48,8936,8937],{"class":50,"line":362},[48,8938,8939],{"class":54},"    popover: globalProps.popover,\n",[48,8941,8942,8945,8948],{"class":50,"line":373},[48,8943,8944],{"class":54},"    maxMainCount: ",[48,8946,8947],{"class":261},"getVisibleLength",[48,8949,8950],{"class":54},"(rowData),\n",[48,8952,8953,8956,8959,8962,8964],{"class":50,"line":384},[48,8954,8955],{"class":261},"    onUpdateAction",[48,8957,8958],{"class":54},": (",[48,8960,8961],{"class":600},"action",[48,8963,806],{"class":54},[48,8965,8670],{"class":62},[48,8967,8968,8971,8973,8976],{"class":50,"line":661},[48,8969,8970],{"class":261},"      emit",[48,8972,624],{"class":54},[48,8974,8975],{"class":69},"'updateAction'",[48,8977,8978],{"class":54},", { action, row: rowData, rowIndex: index }),\n",[48,8980,8981],{"class":50,"line":690},[48,8982,7813],{"class":54},[11,8984,8985,8988],{},[17,8986,8987],{},"markRaw(AActionGroup)"," is the line I want to isolate.",[11,8990,8991,8992,8994,8995,8997,8998,6919,9001,9004],{},"When you pass a component definition to ",[17,8993,7261],{},", Vue touches it during render. Without ",[17,8996,8854],{},", if that component definition ever passes through Vue's reactivity system (a ",[17,8999,9000],{},"ref",[17,9002,9003],{},"reactive",", a prop) it gets wrapped in a Proxy. Component definitions aren't meant to be reactive. They're static metadata. Wrapping them doesn't break anything; it just makes every property read go through a handler that does a reactivity-tracking check that never pays off.",[11,9006,9007,9008,9011,9012,9014,9015,9018],{},"The render function above runs once per row. For a table with 500 rows, the first render touches ",[17,9009,9010],{},"AActionGroup","'s definition 500 times. ",[17,9013,8854],{}," sets a ",[17,9016,9017],{},"__v_skip"," flag on the object once, and every subsequent render skips the Proxy path.",[11,9020,9021,9022,9024,9025,9028,9029,9031],{},"The cost of ",[17,9023,8854],{}," is permanent. You can't un-mark it. That's fine for a component definition: it's a static import. If you wanted to hot-swap the action component at runtime, you'd do that at a different layer (picking ",[2087,9026,9027],{},"which"," component you pass to ",[17,9030,8854],{},"), not by making the reference reactive.",[11,9033,9034,9035,9037,9038,9040,9041,9044,9045,9047],{},"There's also a dev-mode warning (\"Vue received a Component that was made a reactive object\") that fires when a reactive component slips into ",[17,9036,7261],{},". I hit that on a 1200-row table where ",[17,9039,9010],{}," was passed through a ",[17,9042,9043],{},"shallowRef"," for theming. Adding ",[17,9046,8854],{}," silenced it and dropped the first-paint time noticeably.",[11,9049,9050],{},"The visible-action count can be either a number or a function of the row:",[39,9052,9054],{"className":1888,"code":9053,"language":1890,"meta":44,"style":44},"const getVisibleLength = (rowData: T) =>\n  typeof globalProps.visibleActionLength === 'function'\n    ? globalProps.visibleActionLength(rowData)\n    : globalProps.visibleActionLength;\n",[17,9055,9056,9077,9090,9103],{"__ignoreMap":44},[48,9057,9058,9060,9063,9065,9067,9069,9071,9073,9075],{"class":50,"line":51},[48,9059,329],{"class":62},[48,9061,9062],{"class":261}," getVisibleLength",[48,9064,342],{"class":62},[48,9066,2180],{"class":54},[48,9068,8891],{"class":600},[48,9070,336],{"class":62},[48,9072,6784],{"class":261},[48,9074,806],{"class":54},[48,9076,8670],{"class":62},[48,9078,9079,9082,9085,9087],{"class":50,"line":81},[48,9080,9081],{"class":62},"  typeof",[48,9083,9084],{"class":54}," globalProps.visibleActionLength ",[48,9086,653],{"class":62},[48,9088,9089],{"class":69}," 'function'\n",[48,9091,9092,9095,9097,9100],{"class":50,"line":87},[48,9093,9094],{"class":62},"    ?",[48,9096,8409],{"class":54},[48,9098,9099],{"class":261},"visibleActionLength",[48,9101,9102],{"class":54},"(rowData)\n",[48,9104,9105,9108],{"class":50,"line":93},[48,9106,9107],{"class":62},"    :",[48,9109,9110],{"class":54}," globalProps.visibleActionLength;\n",[11,9112,9113],{},"Which is the only way to do \"show 3 actions for admins, 1 for regular users\" without building a second actions column.",[25,9115,9117],{"id":9116},"sorting-a-three-click-cycle-local-or-remote","Sorting: a three-click cycle, local or remote",[11,9119,9120],{},"Sort state is a two-boolean object:",[39,9122,9124],{"className":1888,"code":9123,"language":1890,"meta":44,"style":44},"export type ISortValue = { applied: boolean; revert: boolean };\n",[17,9125,9126],{"__ignoreMap":44},[48,9127,9128,9130,9133,9135,9137,9139,9142,9144,9146,9148,9151,9153,9155],{"class":50,"line":51},[48,9129,3337],{"class":62},[48,9131,9132],{"class":62}," type",[48,9134,6811],{"class":261},[48,9136,342],{"class":62},[48,9138,700],{"class":54},[48,9140,9141],{"class":600},"applied",[48,9143,336],{"class":62},[48,9145,4664],{"class":332},[48,9147,3945],{"class":54},[48,9149,9150],{"class":600},"revert",[48,9152,336],{"class":62},[48,9154,4664],{"class":332},[48,9156,3954],{"class":54},[11,9158,9159,9160,9163],{},"Not ",[17,9161,9162],{},"'asc' | 'desc' | null",". I tried strings first; they kept losing the \"unapplied\" state because falsy values of the wrong type don't coexist gracefully with strict TypeScript.",[11,9165,9166],{},"The toggle cycles through three states:",[39,9168,9170],{"className":1888,"code":9169,"language":1890,"meta":44,"style":44},"function handleToggleSortButton(sorterValue: ISortValue): ISortValue {\n  if (!sorterValue.applied) {\n    sorterValue.applied = true;\n  } else if (!sorterValue.revert) {\n    sorterValue.revert = true;\n  } else {\n    sorterValue.applied = false;\n    sorterValue.revert = false;\n  }\n  return sorterValue;\n}\n",[17,9171,9172,9196,9207,9218,9235,9246,9254,9264,9274,9278,9285],{"__ignoreMap":44},[48,9173,9174,9176,9179,9181,9184,9186,9188,9190,9192,9194],{"class":50,"line":51},[48,9175,2419],{"class":62},[48,9177,9178],{"class":261}," handleToggleSortButton",[48,9180,624],{"class":54},[48,9182,9183],{"class":600},"sorterValue",[48,9185,336],{"class":62},[48,9187,6811],{"class":261},[48,9189,2434],{"class":54},[48,9191,336],{"class":62},[48,9193,6811],{"class":261},[48,9195,1623],{"class":54},[48,9197,9198,9200,9202,9204],{"class":50,"line":81},[48,9199,2450],{"class":62},[48,9201,2180],{"class":54},[48,9203,2455],{"class":62},[48,9205,9206],{"class":54},"sorterValue.applied) {\n",[48,9208,9209,9212,9214,9216],{"class":50,"line":87},[48,9210,9211],{"class":54},"    sorterValue.applied ",[48,9213,66],{"class":62},[48,9215,3167],{"class":332},[48,9217,318],{"class":54},[48,9219,9220,9222,9225,9228,9230,9232],{"class":50,"line":93},[48,9221,2575],{"class":54},[48,9223,9224],{"class":62},"else",[48,9226,9227],{"class":62}," if",[48,9229,2180],{"class":54},[48,9231,2455],{"class":62},[48,9233,9234],{"class":54},"sorterValue.revert) {\n",[48,9236,9237,9240,9242,9244],{"class":50,"line":362},[48,9238,9239],{"class":54},"    sorterValue.revert ",[48,9241,66],{"class":62},[48,9243,3167],{"class":332},[48,9245,318],{"class":54},[48,9247,9248,9250,9252],{"class":50,"line":373},[48,9249,2575],{"class":54},[48,9251,9224],{"class":62},[48,9253,1623],{"class":54},[48,9255,9256,9258,9260,9262],{"class":50,"line":384},[48,9257,9211],{"class":54},[48,9259,66],{"class":62},[48,9261,3295],{"class":332},[48,9263,318],{"class":54},[48,9265,9266,9268,9270,9272],{"class":50,"line":661},[48,9267,9239],{"class":54},[48,9269,66],{"class":62},[48,9271,3295],{"class":332},[48,9273,318],{"class":54},[48,9275,9276],{"class":50,"line":690},[48,9277,2590],{"class":54},[48,9279,9280,9282],{"class":50,"line":695},[48,9281,2963],{"class":62},[48,9283,9284],{"class":54}," sorterValue;\n",[48,9286,9287],{"class":50,"line":722},[48,9288,1717],{"class":54},[11,9290,9291,9292,9295],{},"Click once: ascending. Click again: descending. Click a third time: back to the original order. Users expect this on every table they've ever used, and they're always surprised when a table ",[2087,9293,9294],{},"doesn't"," have the \"unsort\" click.",[11,9297,9298],{},"The actual sorting has two paths: remote and local.",[39,9300,9302],{"className":1888,"code":9301,"language":1890,"meta":44,"style":44},"function sortCell(columnIndex: number) {\n  const column = localColumns.value[columnIndex]!;\n  const sorterFunction = column.sorter;\n  const sorterValue = handleToggleSortButton(column.sortValue!);\n\n  if (globalProps.remote) {\n    emit('updateSorter', { column, sorterValue, rowIndex: columnIndex });\n    return;\n  }\n\n  if (!sorterValue.applied && !sorterValue.revert) {\n    localItems.value = _.cloneDeep(globalProps.items);\n    emit('updateSorter', { column, sorterValue, rowIndex: columnIndex });\n    return;\n  }\n\n  localItems.value = [...localItems.value].sort((a, b) => {\n    const result = sorterFunction!(a, b);\n    return sorterValue.revert ? -result : result;\n  });\n  emit('updateSorter', { column, sorterValue, rowIndex: columnIndex });\n}\n",[17,9303,9304,9322,9338,9350,9368,9372,9379,9392,9398,9402,9406,9424,9440,9450,9456,9460,9464,9497,9513,9533,9537,9548],{"__ignoreMap":44},[48,9305,9306,9308,9311,9313,9316,9318,9320],{"class":50,"line":51},[48,9307,2419],{"class":62},[48,9309,9310],{"class":261}," sortCell",[48,9312,624],{"class":54},[48,9314,9315],{"class":600},"columnIndex",[48,9317,336],{"class":62},[48,9319,3769],{"class":332},[48,9321,2146],{"class":54},[48,9323,9324,9326,9329,9331,9334,9336],{"class":50,"line":81},[48,9325,2604],{"class":62},[48,9327,9328],{"class":332}," column",[48,9330,342],{"class":62},[48,9332,9333],{"class":54}," localColumns.value[columnIndex]",[48,9335,2455],{"class":62},[48,9337,318],{"class":54},[48,9339,9340,9342,9345,9347],{"class":50,"line":87},[48,9341,2604],{"class":62},[48,9343,9344],{"class":332}," sorterFunction",[48,9346,342],{"class":62},[48,9348,9349],{"class":54}," column.sorter;\n",[48,9351,9352,9354,9357,9359,9361,9364,9366],{"class":50,"line":93},[48,9353,2604],{"class":62},[48,9355,9356],{"class":332}," sorterValue",[48,9358,342],{"class":62},[48,9360,9178],{"class":261},[48,9362,9363],{"class":54},"(column.sortValue",[48,9365,2455],{"class":62},[48,9367,629],{"class":54},[48,9369,9370],{"class":50,"line":362},[48,9371,324],{"emptyLinePlaceholder":323},[48,9373,9374,9376],{"class":50,"line":373},[48,9375,2450],{"class":62},[48,9377,9378],{"class":54}," (globalProps.remote) {\n",[48,9380,9381,9384,9386,9389],{"class":50,"line":384},[48,9382,9383],{"class":261},"    emit",[48,9385,624],{"class":54},[48,9387,9388],{"class":69},"'updateSorter'",[48,9390,9391],{"class":54},", { column, sorterValue, rowIndex: columnIndex });\n",[48,9393,9394,9396],{"class":50,"line":661},[48,9395,2946],{"class":62},[48,9397,318],{"class":54},[48,9399,9400],{"class":50,"line":690},[48,9401,2590],{"class":54},[48,9403,9404],{"class":50,"line":695},[48,9405,324],{"emptyLinePlaceholder":323},[48,9407,9408,9410,9412,9414,9417,9419,9422],{"class":50,"line":722},[48,9409,2450],{"class":62},[48,9411,2180],{"class":54},[48,9413,2455],{"class":62},[48,9415,9416],{"class":54},"sorterValue.applied ",[48,9418,2514],{"class":62},[48,9420,9421],{"class":62}," !",[48,9423,9234],{"class":54},[48,9425,9426,9429,9431,9434,9437],{"class":50,"line":743},[48,9427,9428],{"class":54},"    localItems.value ",[48,9430,66],{"class":62},[48,9432,9433],{"class":54}," _.",[48,9435,9436],{"class":261},"cloneDeep",[48,9438,9439],{"class":54},"(globalProps.items);\n",[48,9441,9442,9444,9446,9448],{"class":50,"line":767},[48,9443,9383],{"class":261},[48,9445,624],{"class":54},[48,9447,9388],{"class":69},[48,9449,9391],{"class":54},[48,9451,9452,9454],{"class":50,"line":778},[48,9453,2946],{"class":62},[48,9455,318],{"class":54},[48,9457,9458],{"class":50,"line":783},[48,9459,2590],{"class":54},[48,9461,9462],{"class":50,"line":788},[48,9463,324],{"emptyLinePlaceholder":323},[48,9465,9466,9469,9471,9473,9476,9479,9481,9483,9486,9488,9491,9493,9495],{"class":50,"line":819},[48,9467,9468],{"class":54},"  localItems.value ",[48,9470,66],{"class":62},[48,9472,7432],{"class":54},[48,9474,9475],{"class":62},"...",[48,9477,9478],{"class":54},"localItems.value].",[48,9480,8290],{"class":261},[48,9482,800],{"class":54},[48,9484,9485],{"class":600},"a",[48,9487,158],{"class":54},[48,9489,9490],{"class":600},"b",[48,9492,806],{"class":54},[48,9494,647],{"class":62},[48,9496,1623],{"class":54},[48,9498,9499,9501,9504,9506,9508,9510],{"class":50,"line":828},[48,9500,2201],{"class":62},[48,9502,9503],{"class":332}," result",[48,9505,342],{"class":62},[48,9507,9344],{"class":261},[48,9509,2455],{"class":62},[48,9511,9512],{"class":54},"(a, b);\n",[48,9514,9515,9517,9520,9522,9525,9528,9530],{"class":50,"line":833},[48,9516,2946],{"class":62},[48,9518,9519],{"class":54}," sorterValue.revert ",[48,9521,757],{"class":62},[48,9523,9524],{"class":62}," -",[48,9526,9527],{"class":54},"result ",[48,9529,336],{"class":62},[48,9531,9532],{"class":54}," result;\n",[48,9534,9535],{"class":50,"line":843},[48,9536,7813],{"class":54},[48,9538,9539,9542,9544,9546],{"class":50,"line":860},[48,9540,9541],{"class":261},"  emit",[48,9543,624],{"class":54},[48,9545,9388],{"class":69},[48,9547,9391],{"class":54},[48,9549,9550],{"class":50,"line":887},[48,9551,1717],{"class":54},[11,9553,9554,9555,9558],{},"Remote mode emits and lets the parent handle pagination + sorting against the backend. Local mode runs the sorter in-memory, with one quirk: when the user returns to the \"unsorted\" state, the local items get re-cloned from ",[17,9556,9557],{},"globalProps.items",". That restores original order without needing to remember what it was.",[11,9560,9561],{},"The emit fires in all three cases. Remote backends need it to refetch; local consumers can use it to persist the user's sort choice to a URL query param. Emitting unconditionally is cheaper than branching, and consumers can ignore it.",[25,9563,9565,9566,158,9568,158,9570],{"id":9564},"reactivity-shallowref-clonedeep-debouncedwatch","Reactivity: ",[17,9567,9043],{},[17,9569,9436],{},[17,9571,9572],{},"debouncedWatch",[11,9574,9575,9576,158,9579,158,9582,396,9585,9587,9588,336],{},"The component does not deep-watch the consumer's data. ",[17,9577,9578],{},"localItems",[17,9580,9581],{},"localColumns",[17,9583,9584],{},"localActions",[17,9586,8528],{}," are all ",[17,9589,9043],{},[39,9591,9593],{"className":1888,"code":9592,"language":1890,"meta":44,"style":44},"const localItems = shallowRef\u003CT[]>(_.cloneDeep(globalProps.items));\nconst localColumns = shallowRef\u003CIDataTableColumn\u003CT>[]>([]);\nconst localActions = shallowRef\u003CIAction\u003CT>[]>(_.cloneDeep(globalProps.actions));\nconst localSelectedItems = shallowRef\u003CT[]>(_.cloneDeep(globalProps.selectedItems));\n",[17,9594,9595,9619,9642,9670],{"__ignoreMap":44},[48,9596,9597,9599,9602,9604,9607,9609,9611,9614,9616],{"class":50,"line":51},[48,9598,329],{"class":62},[48,9600,9601],{"class":332}," localItems",[48,9603,342],{"class":62},[48,9605,9606],{"class":261}," shallowRef",[48,9608,55],{"class":54},[48,9610,6587],{"class":261},[48,9612,9613],{"class":54},"[]>(_.",[48,9615,9436],{"class":261},[48,9617,9618],{"class":54},"(globalProps.items));\n",[48,9620,9621,9623,9626,9628,9630,9632,9635,9637,9639],{"class":50,"line":81},[48,9622,329],{"class":62},[48,9624,9625],{"class":332}," localColumns",[48,9627,342],{"class":62},[48,9629,9606],{"class":261},[48,9631,55],{"class":54},[48,9633,9634],{"class":261},"IDataTableColumn",[48,9636,55],{"class":54},[48,9638,6587],{"class":261},[48,9640,9641],{"class":54},">[]>([]);\n",[48,9643,9644,9646,9649,9651,9653,9655,9658,9660,9662,9665,9667],{"class":50,"line":87},[48,9645,329],{"class":62},[48,9647,9648],{"class":332}," localActions",[48,9650,342],{"class":62},[48,9652,9606],{"class":261},[48,9654,55],{"class":54},[48,9656,9657],{"class":261},"IAction",[48,9659,55],{"class":54},[48,9661,6587],{"class":261},[48,9663,9664],{"class":54},">[]>(_.",[48,9666,9436],{"class":261},[48,9668,9669],{"class":54},"(globalProps.actions));\n",[48,9671,9672,9674,9677,9679,9681,9683,9685,9687,9689],{"class":50,"line":93},[48,9673,329],{"class":62},[48,9675,9676],{"class":332}," localSelectedItems",[48,9678,342],{"class":62},[48,9680,9606],{"class":261},[48,9682,55],{"class":54},[48,9684,6587],{"class":261},[48,9686,9613],{"class":54},[48,9688,9436],{"class":261},[48,9690,9691],{"class":54},"(globalProps.selectedItems));\n",[11,9693,9694,9695,9697,9698,9701,9702,9704],{},"A ",[17,9696,9000],{}," wraps arrays in a reactive proxy that tracks every mutation. For a 1000-row table, that's 1000 proxies (and however many nested proxies per row). Most of it is overhead. The component only ever reassigns ",[17,9699,9700],{},".value"," wholesale, never mutates individual rows in place. ",[17,9703,9043],{}," says \"track when the reference changes, don't recurse.\" Which is exactly the contract the component needs.",[11,9706,9707,9708,9711,9712,9714],{},"The cloning is the second half of that story. ",[17,9709,9710],{},"_.cloneDeep(globalProps.items)"," snapshots the parent's data at assignment time; the table operates on its copy. This matters for sort: when the user sorts, the table mutates its ",[17,9713,9578],{}," without affecting the parent's array. Skipping the clone would turn a visual sort into a mutation on the parent's state, which is where the worst class of \"the server-side export didn't match what was on screen\" bugs come from.",[11,9716,9717,9718,336],{},"Re-watching uses ",[17,9719,9572],{},[39,9721,9723],{"className":1888,"code":9722,"language":1890,"meta":44,"style":44},"debouncedWatch(\n  () => globalProps.items,\n  () => (localItems.value = _.cloneDeep(globalProps.items)),\n  { deep: true, debounce: 100 }\n);\n\ndebouncedWatch(\n  () => globalProps.selectedItems,\n  () => (localSelectedItems.value = _.cloneDeep(globalProps.selectedItems)),\n  { deep: true, immediate: true, debounce: 50 }\n);\n",[17,9724,9725,9731,9740,9758,9773,9777,9781,9787,9796,9814,9832],{"__ignoreMap":44},[48,9726,9727,9729],{"class":50,"line":51},[48,9728,9572],{"class":261},[48,9730,2111],{"class":54},[48,9732,9733,9735,9737],{"class":50,"line":81},[48,9734,3066],{"class":54},[48,9736,647],{"class":62},[48,9738,9739],{"class":54}," globalProps.items,\n",[48,9741,9742,9744,9746,9749,9751,9753,9755],{"class":50,"line":87},[48,9743,3066],{"class":54},[48,9745,647],{"class":62},[48,9747,9748],{"class":54}," (localItems.value ",[48,9750,66],{"class":62},[48,9752,9433],{"class":54},[48,9754,9436],{"class":261},[48,9756,9757],{"class":54},"(globalProps.items)),\n",[48,9759,9760,9763,9765,9768,9771],{"class":50,"line":93},[48,9761,9762],{"class":54},"  { deep: ",[48,9764,773],{"class":332},[48,9766,9767],{"class":54},", debounce: ",[48,9769,9770],{"class":332},"100",[48,9772,2244],{"class":54},[48,9774,9775],{"class":50,"line":362},[48,9776,629],{"class":54},[48,9778,9779],{"class":50,"line":373},[48,9780,324],{"emptyLinePlaceholder":323},[48,9782,9783,9785],{"class":50,"line":384},[48,9784,9572],{"class":261},[48,9786,2111],{"class":54},[48,9788,9789,9791,9793],{"class":50,"line":661},[48,9790,3066],{"class":54},[48,9792,647],{"class":62},[48,9794,9795],{"class":54}," globalProps.selectedItems,\n",[48,9797,9798,9800,9802,9805,9807,9809,9811],{"class":50,"line":690},[48,9799,3066],{"class":54},[48,9801,647],{"class":62},[48,9803,9804],{"class":54}," (localSelectedItems.value ",[48,9806,66],{"class":62},[48,9808,9433],{"class":54},[48,9810,9436],{"class":261},[48,9812,9813],{"class":54},"(globalProps.selectedItems)),\n",[48,9815,9816,9818,9820,9823,9825,9827,9830],{"class":50,"line":695},[48,9817,9762],{"class":54},[48,9819,773],{"class":332},[48,9821,9822],{"class":54},", immediate: ",[48,9824,773],{"class":332},[48,9826,9767],{"class":54},[48,9828,9829],{"class":332},"50",[48,9831,2244],{"class":54},[48,9833,9834],{"class":50,"line":722},[48,9835,629],{"class":54},[11,9837,9838],{},"Why debounce? Because parent state often thrashes. A filter change at the parent level can trigger three re-renders in quick succession: form update, query string update, fetched response replacing the placeholder. Without a debounce, the table would clone its items three times in a single frame. 100ms for items and 50ms for selection are numbers I landed on by eyeballing the production behavior; they're not hallowed. If I had the appetite I'd make them props.",[11,9840,9841,9842,9844],{},"The columns\u002Factions watcher is ",[2087,9843,3234],{}," debounced:",[39,9846,9848],{"className":1888,"code":9847,"language":1890,"meta":44,"style":44},"localColumns.value = handleColumns();\nwatch(\n  () => [globalProps.columns, globalProps.actions],\n  () => {\n    localActions.value = _.cloneDeep(globalProps.actions);\n    localColumns.value = handleColumns();\n  },\n  { deep: true, immediate: true }\n);\n",[17,9849,9850,9862,9868,9877,9885,9899,9910,9914,9926],{"__ignoreMap":44},[48,9851,9852,9855,9857,9860],{"class":50,"line":51},[48,9853,9854],{"class":54},"localColumns.value ",[48,9856,66],{"class":62},[48,9858,9859],{"class":261}," handleColumns",[48,9861,3473],{"class":54},[48,9863,9864,9866],{"class":50,"line":81},[48,9865,2108],{"class":261},[48,9867,2111],{"class":54},[48,9869,9870,9872,9874],{"class":50,"line":87},[48,9871,3066],{"class":54},[48,9873,647],{"class":62},[48,9875,9876],{"class":54}," [globalProps.columns, globalProps.actions],\n",[48,9878,9879,9881,9883],{"class":50,"line":93},[48,9880,3066],{"class":54},[48,9882,647],{"class":62},[48,9884,1623],{"class":54},[48,9886,9887,9890,9892,9894,9896],{"class":50,"line":362},[48,9888,9889],{"class":54},"    localActions.value ",[48,9891,66],{"class":62},[48,9893,9433],{"class":54},[48,9895,9436],{"class":261},[48,9897,9898],{"class":54},"(globalProps.actions);\n",[48,9900,9901,9904,9906,9908],{"class":50,"line":373},[48,9902,9903],{"class":54},"    localColumns.value ",[48,9905,66],{"class":62},[48,9907,9859],{"class":261},[48,9909,3473],{"class":54},[48,9911,9912],{"class":50,"line":384},[48,9913,2234],{"class":54},[48,9915,9916,9918,9920,9922,9924],{"class":50,"line":661},[48,9917,9762],{"class":54},[48,9919,773],{"class":332},[48,9921,9822],{"class":54},[48,9923,773],{"class":332},[48,9925,2244],{"class":54},[48,9927,9928],{"class":50,"line":690},[48,9929,629],{"class":54},[11,9931,9932,9933,9936],{},"Column and action changes are rare. Usually they're declared once at the top of a ",[17,9934,9935],{},"\u003Cscript setup>"," and never touched again. A debounce would introduce perceptible lag for the uncommon case where they do change (e.g. an admin toggling a column visibility). The asymmetry (debounce items, don't debounce columns) maps to how consumers actually use the component.",[25,9938,9940],{"id":9939},"loading-empty-paginated-the-surrounding-chrome","Loading, empty, paginated: the surrounding chrome",[11,9942,9943],{},"The surrounding chrome is a small story that nobody writes down.",[11,9945,9946,9947,336],{},"The loading state is an absolutely-positioned overlay with ",[17,9948,9949],{},"pointer-events-none",[39,9951,9953],{"className":41,"code":9952,"language":43,"meta":44,"style":44},"\u003Cdiv\n  v-if=\"globalProps.loading\"\n  class=\"bg-white\u002F70 flex pointer-events-none items-center inset-0 justify-center absolute z-10 backdrop-blur-[1px]\"\n>\n  \u003CLoadingComp \u002F>\n\u003C\u002Fdiv>\n",[17,9954,9955,9961,9975,9984,9988,9993],{"__ignoreMap":44},[48,9956,9957,9959],{"class":50,"line":51},[48,9958,55],{"class":54},[48,9960,1067],{"class":58},[48,9962,9963,9966,9968,9970,9973],{"class":50,"line":81},[48,9964,9965],{"class":62},"  v-if",[48,9967,66],{"class":54},[48,9969,70],{"class":69},[48,9971,9972],{"class":54},"globalProps.loading",[48,9974,4186],{"class":69},[48,9976,9977,9979,9981],{"class":50,"line":87},[48,9978,1072],{"class":261},[48,9980,66],{"class":54},[48,9982,9983],{"class":69},"\"bg-white\u002F70 flex pointer-events-none items-center inset-0 justify-center absolute z-10 backdrop-blur-[1px]\"\n",[48,9985,9986],{"class":50,"line":93},[48,9987,78],{"class":54},[48,9989,9990],{"class":50,"line":362},[48,9991,9992],{"class":54},"  \u003CLoadingComp \u002F>\n",[48,9994,9995,9997,9999],{"class":50,"line":373},[48,9996,96],{"class":54},[48,9998,59],{"class":58},[48,10000,78],{"class":54},[11,10002,10003,10005],{},[17,10004,9949],{}," is the detail. Without it, the overlay intercepts clicks on the rows underneath. Even though it's visually translucent, the user can't interact with the blurred table. The intent is \"show that something's happening, don't block the user from selecting a row they can still see.\" The overlay is just a hint.",[11,10007,10008],{},"The empty state is a row inside the table, not a sibling:",[39,10010,10012],{"className":41,"code":10011,"language":43,"meta":44,"style":44},"\u003CATBody :columns=\"localColumns\" :items=\"localItems\">\n  \u003Ctr v-if=\"localItems.length === 0 && !globalProps.loading\">\n    \u003Ctd :colspan=\"localColumns.length\" class=\"p-4\">\n      \u003CNoData :title=\"globalProps.noDataTitle\" \u002F>\n    \u003C\u002Ftd>\n  \u003C\u002Ftr>\n\u003C\u002FATBody>\n",[17,10013,10014,10048,10053,10058,10063,10068,10072],{"__ignoreMap":44},[48,10015,10016,10018,10020,10022,10025,10027,10029,10031,10033,10035,10038,10040,10042,10044,10046],{"class":50,"line":51},[48,10017,55],{"class":54},[48,10019,6909],{"class":58},[48,10021,258],{"class":54},[48,10023,10024],{"class":261},"columns",[48,10026,66],{"class":54},[48,10028,70],{"class":69},[48,10030,9581],{"class":54},[48,10032,70],{"class":69},[48,10034,258],{"class":54},[48,10036,10037],{"class":261},"items",[48,10039,66],{"class":54},[48,10041,70],{"class":69},[48,10043,9578],{"class":54},[48,10045,70],{"class":69},[48,10047,78],{"class":54},[48,10049,10050],{"class":50,"line":81},[48,10051,10052],{"class":54},"  \u003Ctr v-if=\"localItems.length === 0 && !globalProps.loading\">\n",[48,10054,10055],{"class":50,"line":87},[48,10056,10057],{"class":54},"    \u003Ctd :colspan=\"localColumns.length\" class=\"p-4\">\n",[48,10059,10060],{"class":50,"line":93},[48,10061,10062],{"class":54},"      \u003CNoData :title=\"globalProps.noDataTitle\" \u002F>\n",[48,10064,10065],{"class":50,"line":362},[48,10066,10067],{"class":54},"    \u003C\u002Ftd>\n",[48,10069,10070],{"class":50,"line":373},[48,10071,7188],{"class":54},[48,10073,10074,10076,10078],{"class":50,"line":384},[48,10075,96],{"class":54},[48,10077,6909],{"class":58},[48,10079,78],{"class":54},[11,10081,10082,10083,10085,10086,10089,10090,10093],{},"Piping it through the ",[17,10084,6922],{},"'s slot is how it inherits the table's border and spacing. A sibling ",[17,10087,10088],{},"\u003Cdiv>"," would need separate styles to line up; a ",[17,10091,10092],{},"\u003Ctr colspan>"," gets layout for free.",[11,10095,10096,10097,10100],{},"Pagination is optional and driven by the ",[17,10098,10099],{},"pagination"," prop:",[39,10102,10104],{"className":41,"code":10103,"language":43,"meta":44,"style":44},"\u003Cdiv v-if=\"globalProps.pagination && !globalProps.loading\">\n  \u003CAPagination\n    :model-value=\"globalProps.pagination.modelValue\"\n    :page-size=\"globalProps.pagination.pageSize\"\n    :total-items=\"globalProps.pagination.totalItems\"\n    @update:page-size=\"globalProps.pagination.onPageSizeChange\"\n    @update:model-value=\"globalProps.pagination.onUpdateCurrentPage\"\n  \u002F>\n\u003C\u002Fdiv>\n",[17,10105,10106,10131,10136,10141,10146,10151,10156,10161,10166],{"__ignoreMap":44},[48,10107,10108,10110,10112,10114,10116,10118,10121,10123,10125,10127,10129],{"class":50,"line":51},[48,10109,55],{"class":54},[48,10111,59],{"class":58},[48,10113,63],{"class":62},[48,10115,66],{"class":54},[48,10117,70],{"class":69},[48,10119,10120],{"class":54},"globalProps.pagination ",[48,10122,2514],{"class":62},[48,10124,9421],{"class":62},[48,10126,9972],{"class":54},[48,10128,70],{"class":69},[48,10130,78],{"class":54},[48,10132,10133],{"class":50,"line":81},[48,10134,10135],{"class":54},"  \u003CAPagination\n",[48,10137,10138],{"class":50,"line":87},[48,10139,10140],{"class":54},"    :model-value=\"globalProps.pagination.modelValue\"\n",[48,10142,10143],{"class":50,"line":93},[48,10144,10145],{"class":54},"    :page-size=\"globalProps.pagination.pageSize\"\n",[48,10147,10148],{"class":50,"line":362},[48,10149,10150],{"class":54},"    :total-items=\"globalProps.pagination.totalItems\"\n",[48,10152,10153],{"class":50,"line":373},[48,10154,10155],{"class":54},"    @update:page-size=\"globalProps.pagination.onPageSizeChange\"\n",[48,10157,10158],{"class":50,"line":384},[48,10159,10160],{"class":54},"    @update:model-value=\"globalProps.pagination.onUpdateCurrentPage\"\n",[48,10162,10163],{"class":50,"line":661},[48,10164,10165],{"class":54},"  \u002F>\n",[48,10167,10168,10170,10172],{"class":50,"line":690},[48,10169,96],{"class":54},[48,10171,59],{"class":58},[48,10173,78],{"class":54},[11,10175,10176,10177,10179,10180,158,10183,10186],{},"Two things worth flagging. The pagination component is hidden during loading. Without this, a page-size dropdown would be active during fetch, and a second rapid click could fire a second page request. And the callbacks are passed inside the ",[17,10178,10099],{}," object as fields (",[17,10181,10182],{},"onPageSizeChange",[17,10184,10185],{},"onUpdateCurrentPage",") rather than as separate props. I'm not sure that was the right call; emitting would have been more idiomatic Vue. But it keeps the \"pagination is a single config blob\" story clean.",[25,10188,10190],{"id":10189},"what-id-change-on-a-fourth-rewrite","What I'd change on a fourth rewrite",[11,10192,10193],{},"A few places where today's code isn't the version I'd write fresh:",[8453,10195,10196,10209,10225,10240,10246],{},[3669,10197,10198,10208],{},[35,10199,2251,10200,10202,10203,10205,10206,526],{},[17,10201,7656],{}," vs ",[17,10204,7660],{}," duplication in ",[17,10207,6912],{}," Two separate props pointing at the same data. Collapsing to one prop would remove one spread and one branch. I left it because old call-sites depend on the name.",[3669,10210,10211,10216,10217,10220,10221,10224],{},[35,10212,10213,10215],{},[17,10214,8084],{}," for default cells."," The footgun is real; the ergonomics are worth it. If I did it over I'd either go ",[17,10218,10219],{},"textContent"," by default and make HTML opt-in via a column field (",[17,10222,10223],{},"unsafe: true","), or commit fully to a slot-based approach. Both are refactors; neither is urgent.",[3669,10226,10227,10232,10233,10236,10237,10239],{},[35,10228,2251,10229,10231],{},[17,10230,7109],{}," cast in the header."," Works, but an optional ",[17,10234,10235],{},"record?: S"," on ",[17,10238,6912],{}," would be more honest. Headers don't have a record; say so in the type.",[3669,10241,10242,10245],{},[35,10243,10244],{},"The hardcoded debounce values (50ms, 100ms)."," Should be props. Three minutes of work; I keep not doing it.",[3669,10247,10248,10258],{},[35,10249,10250,10251,10254,10255,10257],{},"The computed ",[17,10252,10253],{},"Cell"," paired with ",[17,10256,7681],{}," keyed on sort state."," Still squinting at this one. Either the key is unnecessary (delete it and watch what happens) or it's hiding a timing bug in the patcher I should reproduce. I owe the code base that investigation.",[11,10260,10261,10262,10264,10265,10267,10268,10270],{},"None of those are the reason I'd recommend this pattern. The reason is smaller and duller: the column-as-data schema, the ",[17,10263,9043],{}," + clone + debounce rhythm, the Map-keyed selection, the ",[17,10266,8854],{}," on the per-row component. Each of them is the kind of thing you write the second time, after the first version has bitten you once. Which is the whole story of ",[17,10269,6437],{},". The shape it landed in is the shape of a thing I stopped rewriting.",[218,10272,10273],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":44,"searchDepth":81,"depth":81,"links":10275},[10276,10278,10280,10285,10287,10288,10290,10292,10293,10295,10296],{"id":6444,"depth":81,"text":10277},"From IColumn class to typed interface",{"id":6902,"depth":81,"text":10279},"The render trio: ATHead, ATBody, ATCell",{"id":7255,"depth":81,"text":10281,"children":10282},"ATCell: one component, two shapes, built via h()",[10283],{"id":7667,"depth":87,"text":10284},"The :key=\"sortValue?.applied\" that looks like a typo",{"id":7696,"depth":81,"text":10286},"handleValueBasedOnKey: dot-paths into rows",{"id":8098,"depth":81,"text":8099},{"id":8518,"depth":81,"text":10289},"Selection as a Map\u003Cstring, T>",{"id":8850,"depth":81,"text":10291},"Actions, and the markRaw that earned its keep",{"id":9116,"depth":81,"text":9117},{"id":9564,"depth":81,"text":10294},"Reactivity: shallowRef, cloneDeep, debouncedWatch",{"id":9939,"depth":81,"text":9940},{"id":10189,"depth":81,"text":10190},"2026-04-19","A typed generic Vue data table with schema-shaped columns, Map-keyed selection, markRaw per-row actions, and a three-click sort cycle. A full tour.","جولة كاملة في جدول بيانات Vue مُعمَّم بأنواع صارمة، بأعمدة مُعرَّفة كسكيمة، وتحديد صفوف قائم على Map، وأزرار إجراءات لكل صف عبر markRaw، ودورة فرز من ثلاث نقرات.",[10301,10302,8854,9043,10303,10304,10305,9572,10306,1840],"Vue 3 data table","TypeScript generic component","row selection","column sorting","dot-path resolution","UI component",{},"\u002Fwrites\u002Fanatomy-of-adatatable",{"title":6422,"description":10298},"writes\u002Fanatomy-of-adatatable","تشريح ADataTable، جدول بيانات مُعمَّم بأنواع في Vue","BpPooy2AKNC4CQ_dczfkSooGjmZ1gRZuFcU7i8jT2-I",{"id":10314,"title":10315,"body":10316,"date":12176,"description":12177,"description_ar":12178,"draft":1835,"extension":1836,"image":1837,"keywords":12179,"lang":1848,"meta":12184,"navigation":323,"path":12185,"seo":12186,"stem":12187,"title_ar":12188,"updatedAt":12176,"__hash__":12189},"writes\u002Fwrites\u002Finside-asentinel-pagination.md","Four bugs every infinite-scroll list has, and their fixes",{"type":8,"value":10317,"toc":12159},[10318,10321,10331,10341,10344,10348,10351,10441,10448,10455,10466,10473,10476,10632,10646,10649,10710,10713,10790,10797,10804,10811,10830,10845,10852,10923,10928,11194,11212,11216,11222,11257,11260,11388,11406,11410,11417,11424,11427,11569,11572,11583,11590,11597,11600,11615,11621,11625,11642,11645,11648,11688,11712,11719,11722,11845,11863,11873,11880,11883,11886,12104,12129,12139,12143,12149,12156],[11,10319,10320],{},"Infinite scroll is one of those features that looks trivial on a whiteboard and gets weird under real usage. The happy path fits on a slide: an IntersectionObserver at the bottom of the list, a fetch on intersect, append the results. Ship it.",[11,10322,10323,10324,10327,10328,526],{},"Then the bugs find you. The user changes a filter and the next-page call still returns the old filter's data. Two \"load more\" requests go out in the same tick because the user scrolled fast. The component unmounts while an observer is still alive and the callback closure keeps the whole page reachable from GC. The handler works in development but not in prod because someone used ",[17,10325,10326],{},"fetcher()"," instead of ",[17,10329,10330],{},"fetcher(page)",[11,10332,10333,10336,10337,10340],{},[17,10334,10335],{},"ASentinelPagination"," is my answer. It's ~230 lines in a single ",[17,10338,10339],{},".vue"," file, and it isn't clever. What's worth writing about isn't the architecture. It's the list of specific bugs it avoids and the specific lines that avoid them. That's what this post is.",[11,10342,10343],{},"I'll frame the whole thing around four bugs I've shipped at least once in earlier attempts.",[25,10345,10347],{"id":10346},"the-api-the-consumer-writes","The API the consumer writes",[11,10349,10350],{},"Before the bugs, here's what a consumer actually writes:",[39,10352,10354],{"className":41,"code":10353,"language":43,"meta":44,"style":44},"\u003CASentinelPagination :fetch-handler=\"loadProducts\">\n  \u003Ctemplate #card=\"{ item }\">\n    \u003CProductCard :product=\"item\" \u002F>\n  \u003C\u002Ftemplate>\n  \u003Ctemplate #initialLoading=\"{ count }\">\n    \u003CProductCardSkeleton v-for=\"i in count\" :key=\"i\" \u002F>\n  \u003C\u002Ftemplate>\n  \u003Ctemplate #loadingMore=\"{ count }\">\n    \u003CProductCardSkeleton v-for=\"i in count\" :key=\"i\" \u002F>\n  \u003C\u002Ftemplate>\n  \u003Ctemplate #emptyState>\n    \u003CNoResults \u002F>\n  \u003C\u002Ftemplate>\n\u003C\u002FASentinelPagination>\n",[17,10355,10356,10378,10383,10388,10392,10397,10402,10406,10411,10415,10419,10424,10429,10433],{"__ignoreMap":44},[48,10357,10358,10360,10362,10364,10367,10369,10371,10374,10376],{"class":50,"line":51},[48,10359,55],{"class":54},[48,10361,10335],{"class":58},[48,10363,258],{"class":54},[48,10365,10366],{"class":261},"fetch-handler",[48,10368,66],{"class":54},[48,10370,70],{"class":69},[48,10372,10373],{"class":54},"loadProducts",[48,10375,70],{"class":69},[48,10377,78],{"class":54},[48,10379,10380],{"class":50,"line":81},[48,10381,10382],{"class":54},"  \u003Ctemplate #card=\"{ item }\">\n",[48,10384,10385],{"class":50,"line":87},[48,10386,10387],{"class":54},"    \u003CProductCard :product=\"item\" \u002F>\n",[48,10389,10390],{"class":50,"line":93},[48,10391,5235],{"class":54},[48,10393,10394],{"class":50,"line":362},[48,10395,10396],{"class":54},"  \u003Ctemplate #initialLoading=\"{ count }\">\n",[48,10398,10399],{"class":50,"line":373},[48,10400,10401],{"class":54},"    \u003CProductCardSkeleton v-for=\"i in count\" :key=\"i\" \u002F>\n",[48,10403,10404],{"class":50,"line":384},[48,10405,5235],{"class":54},[48,10407,10408],{"class":50,"line":661},[48,10409,10410],{"class":54},"  \u003Ctemplate #loadingMore=\"{ count }\">\n",[48,10412,10413],{"class":50,"line":690},[48,10414,10401],{"class":54},[48,10416,10417],{"class":50,"line":695},[48,10418,5235],{"class":54},[48,10420,10421],{"class":50,"line":722},[48,10422,10423],{"class":54},"  \u003Ctemplate #emptyState>\n",[48,10425,10426],{"class":50,"line":743},[48,10427,10428],{"class":54},"    \u003CNoResults \u002F>\n",[48,10430,10431],{"class":50,"line":767},[48,10432,5235],{"class":54},[48,10434,10435,10437,10439],{"class":50,"line":778},[48,10436,96],{"class":54},[48,10438,10335],{"class":58},[48,10440,78],{"class":54},[11,10442,10443,10444,10447],{},"Four slots, one prop that matters. The handler returns ",[17,10445,10446],{},"{ items: T[], pagination?: { current_page, per_page, total, last_page } }",". Pagination is optional. If the backend doesn't return it, the list loads once and stops. That's the degraded-but-honest behavior.",[25,10449,10451,10454],{"id":10450},"fetchhandlerlength-arity-based-dispatch",[17,10452,10453],{},"fetchHandler.length",": arity-based dispatch",[11,10456,10457,10458,10461,10462,10465],{},"Some APIs want a page number, some don't. Some endpoints paginate server-side, others return everything. If the component forces one shape, consumers end up writing adapter closures to force their fetcher into the expected signature. If the component forces the ",[2087,10459,10460],{},"other"," shape, consumers who don't care about pagination end up writing ",[17,10463,10464],{},"(_page) => ..."," everywhere and ignoring the argument.",[11,10467,10468,10469,10472],{},"The wrong fix is adding a ",[17,10470,10471],{},"mode: 'paged' | 'stateless'"," prop. It's a config for something the function's own shape already expresses.",[11,10474,10475],{},"The right fix turns out to be inspecting the function at runtime:",[39,10477,10479],{"className":1888,"code":10478,"language":1890,"meta":44,"style":44},"async function runFetchHandler(): Promise\u003CISPPaginationHandlerResult\u003CT>> {\n  if (props.fetchHandler.length >= 1) {\n    const fn = props.fetchHandler as (\n      p: ISPPaginationMeta\n    ) => Promise\u003CISPPaginationHandlerResult\u003CT>>;\n    return await fn(pagination.value);\n  }\n\n  const fn = props.fetchHandler as () => Promise\u003CISPPaginationHandlerResult\u003CT>>;\n  return await fn();\n}\n",[17,10480,10481,10509,10524,10541,10551,10571,10582,10586,10590,10618,10628],{"__ignoreMap":44},[48,10482,10483,10485,10487,10490,10493,10495,10497,10499,10502,10504,10506],{"class":50,"line":51},[48,10484,3746],{"class":62},[48,10486,3343],{"class":62},[48,10488,10489],{"class":261}," runFetchHandler",[48,10491,10492],{"class":54},"()",[48,10494,336],{"class":62},[48,10496,3366],{"class":261},[48,10498,55],{"class":54},[48,10500,10501],{"class":261},"ISPPaginationHandlerResult",[48,10503,55],{"class":54},[48,10505,6587],{"class":261},[48,10507,10508],{"class":54},">> {\n",[48,10510,10511,10513,10516,10518,10520,10522],{"class":50,"line":81},[48,10512,2450],{"class":62},[48,10514,10515],{"class":54}," (props.fetchHandler.",[48,10517,2627],{"class":332},[48,10519,2630],{"class":62},[48,10521,2812],{"class":332},[48,10523,2146],{"class":54},[48,10525,10526,10528,10531,10533,10536,10538],{"class":50,"line":87},[48,10527,2201],{"class":62},[48,10529,10530],{"class":332}," fn",[48,10532,342],{"class":62},[48,10534,10535],{"class":54}," props.fetchHandler ",[48,10537,2658],{"class":62},[48,10539,10540],{"class":54}," (\n",[48,10542,10543,10546,10548],{"class":50,"line":93},[48,10544,10545],{"class":600},"      p",[48,10547,336],{"class":62},[48,10549,10550],{"class":261}," ISPPaginationMeta\n",[48,10552,10553,10556,10558,10560,10562,10564,10566,10568],{"class":50,"line":362},[48,10554,10555],{"class":54},"    ) ",[48,10557,647],{"class":62},[48,10559,3366],{"class":261},[48,10561,55],{"class":54},[48,10563,10501],{"class":261},[48,10565,55],{"class":54},[48,10567,6587],{"class":261},[48,10569,10570],{"class":54},">>;\n",[48,10572,10573,10575,10577,10579],{"class":50,"line":373},[48,10574,2946],{"class":62},[48,10576,3551],{"class":62},[48,10578,10530],{"class":261},[48,10580,10581],{"class":54},"(pagination.value);\n",[48,10583,10584],{"class":50,"line":384},[48,10585,2590],{"class":54},[48,10587,10588],{"class":50,"line":661},[48,10589,324],{"emptyLinePlaceholder":323},[48,10591,10592,10594,10596,10598,10600,10602,10604,10606,10608,10610,10612,10614,10616],{"class":50,"line":690},[48,10593,2604],{"class":62},[48,10595,10530],{"class":332},[48,10597,342],{"class":62},[48,10599,10535],{"class":54},[48,10601,2658],{"class":62},[48,10603,4427],{"class":54},[48,10605,647],{"class":62},[48,10607,3366],{"class":261},[48,10609,55],{"class":54},[48,10611,10501],{"class":261},[48,10613,55],{"class":54},[48,10615,6587],{"class":261},[48,10617,10570],{"class":54},[48,10619,10620,10622,10624,10626],{"class":50,"line":695},[48,10621,2963],{"class":62},[48,10623,3551],{"class":62},[48,10625,10530],{"class":261},[48,10627,3473],{"class":54},[48,10629,10630],{"class":50,"line":722},[48,10631,1717],{"class":54},[11,10633,10634,10637,10638,10641,10642,10645],{},[17,10635,10636],{},"Function.prototype.length"," returns how many formal parameters the function declares. If the consumer wrote ",[17,10639,10640],{},"loadProducts(p)",", it's 1; if they wrote ",[17,10643,10644],{},"loadProducts()",", it's 0. The component branches on that.",[11,10647,10648],{},"From the consumer's side, both are legal:",[39,10650,10652],{"className":1888,"code":10651,"language":1890,"meta":44,"style":44},"const loadAllProducts = () => $api('\u002Fproducts');                                   \u002F\u002F stateless\nconst loadProductsPage = (p) => $api('\u002Fproducts', { query: { page: p.current_page } }); \u002F\u002F paged\n",[17,10653,10654,10681],{"__ignoreMap":44},[48,10655,10656,10658,10661,10663,10665,10667,10670,10672,10675,10678],{"class":50,"line":51},[48,10657,329],{"class":62},[48,10659,10660],{"class":261}," loadAllProducts",[48,10662,342],{"class":62},[48,10664,4427],{"class":54},[48,10666,647],{"class":62},[48,10668,10669],{"class":261}," $api",[48,10671,624],{"class":54},[48,10673,10674],{"class":69},"'\u002Fproducts'",[48,10676,10677],{"class":54},");                                   ",[48,10679,10680],{"class":2473},"\u002F\u002F stateless\n",[48,10682,10683,10685,10688,10690,10692,10694,10696,10698,10700,10702,10704,10707],{"class":50,"line":81},[48,10684,329],{"class":62},[48,10686,10687],{"class":261}," loadProductsPage",[48,10689,342],{"class":62},[48,10691,2180],{"class":54},[48,10693,11],{"class":600},[48,10695,806],{"class":54},[48,10697,647],{"class":62},[48,10699,10669],{"class":261},[48,10701,624],{"class":54},[48,10703,10674],{"class":69},[48,10705,10706],{"class":54},", { query: { page: p.current_page } }); ",[48,10708,10709],{"class":2473},"\u002F\u002F paged\n",[11,10711,10712],{},"The TypeScript union in the prop type keeps both signatures honest:",[39,10714,10716],{"className":1888,"code":10715,"language":1890,"meta":44,"style":44},"export type ISPFetchHandler\u003CT> =\n  | ((pagination: ISPPaginationMeta) => Promise\u003CISPPaginationHandlerResult\u003CT>>)\n  | (() => Promise\u003CISPPaginationHandlerResult\u003CT>>);\n",[17,10717,10718,10736,10768],{"__ignoreMap":44},[48,10719,10720,10722,10724,10727,10729,10731,10733],{"class":50,"line":51},[48,10721,3337],{"class":62},[48,10723,9132],{"class":62},[48,10725,10726],{"class":261}," ISPFetchHandler",[48,10728,55],{"class":54},[48,10730,6587],{"class":261},[48,10732,5531],{"class":54},[48,10734,10735],{"class":62},"=\n",[48,10737,10738,10741,10744,10746,10748,10751,10753,10755,10757,10759,10761,10763,10765],{"class":50,"line":81},[48,10739,10740],{"class":62},"  |",[48,10742,10743],{"class":54}," ((",[48,10745,10099],{"class":600},[48,10747,336],{"class":62},[48,10749,10750],{"class":261}," ISPPaginationMeta",[48,10752,806],{"class":54},[48,10754,647],{"class":62},[48,10756,3366],{"class":261},[48,10758,55],{"class":54},[48,10760,10501],{"class":261},[48,10762,55],{"class":54},[48,10764,6587],{"class":261},[48,10766,10767],{"class":54},">>)\n",[48,10769,10770,10772,10775,10777,10779,10781,10783,10785,10787],{"class":50,"line":87},[48,10771,10740],{"class":62},[48,10773,10774],{"class":54}," (() ",[48,10776,647],{"class":62},[48,10778,3366],{"class":261},[48,10780,55],{"class":54},[48,10782,10501],{"class":261},[48,10784,55],{"class":54},[48,10786,6587],{"class":261},[48,10788,10789],{"class":54},">>);\n",[11,10791,10792,10793,10796],{},"There's one trap worth naming. Arrow functions that declare a parameter but never use it still count as arity 1. If a consumer writes ",[17,10794,10795],{},"(_p) => $api('\u002Fproducts')",", it hits the paged branch. That's fine (the handler ignores the arg) but it's the kind of thing to write down once so the next person asking \"why is pagination being passed?\" can read the answer.",[25,10798,10800,10803],{"id":10799},"inflight-one-gate-three-derived-readouts",[17,10801,10802],{},"inFlight",": one gate, three derived readouts",[11,10805,10806,10807,10810],{},"This is the classic. User hits the bottom, observer fires, fetch starts. User scrolls a pixel, observer fires ",[2087,10808,10809],{},"again"," (the sentinel is still on screen), and a second fetch starts before the first one resolved. Depending on timing, you get:",[8453,10812,10813,10816,10827],{},[3669,10814,10815],{},"Both requests return page 2; items duplicate.",[3669,10817,10818,10819,10822,10823,10826],{},"One fires with ",[17,10820,10821],{},"current_page = 2",", the other with ",[17,10824,10825],{},"current_page = 3","; items are interleaved or page 2 is silently skipped.",[3669,10828,10829],{},"Both return the same page, the consumer's de-dup logic breaks, and the layout shifts.",[11,10831,10832,10833,10836,10837,10840,10841,10844],{},"The naïve fix is one boolean (",[17,10834,10835],{},"isLoading","). It doesn't quite work because there are ",[2087,10838,10839],{},"two"," logically different loads: the initial one (full-screen skeleton) and the next-page one (small skeleton at the bottom). Two booleans can disagree, and once they do, the gate logic starts branching on \"is ",[2087,10842,10843],{},"this"," kind of load in progress?\" which is the wrong question.",[11,10846,10847,10848,10851],{},"The right question is \"is ",[2087,10849,10850],{},"anything"," in progress?\" One source of truth, two derived readouts:",[39,10853,10855],{"className":1888,"code":10854,"language":1890,"meta":44,"style":44},"const initialLoading = ref(true);\nconst isFetchingMore = ref(false);\nconst inFlight = ref\u003C'init' | 'next' | null>(null);\n",[17,10856,10857,10874,10892],{"__ignoreMap":44},[48,10858,10859,10861,10864,10866,10868,10870,10872],{"class":50,"line":51},[48,10860,329],{"class":62},[48,10862,10863],{"class":332}," initialLoading",[48,10865,342],{"class":62},[48,10867,621],{"class":261},[48,10869,624],{"class":54},[48,10871,773],{"class":332},[48,10873,629],{"class":54},[48,10875,10876,10878,10881,10883,10885,10887,10890],{"class":50,"line":81},[48,10877,329],{"class":62},[48,10879,10880],{"class":332}," isFetchingMore",[48,10882,342],{"class":62},[48,10884,621],{"class":261},[48,10886,624],{"class":54},[48,10888,10889],{"class":332},"false",[48,10891,629],{"class":54},[48,10893,10894,10896,10899,10901,10903,10905,10908,10910,10913,10915,10917,10919,10921],{"class":50,"line":87},[48,10895,329],{"class":62},[48,10897,10898],{"class":332}," inFlight",[48,10900,342],{"class":62},[48,10902,621],{"class":261},[48,10904,55],{"class":54},[48,10906,10907],{"class":69},"'init'",[48,10909,678],{"class":62},[48,10911,10912],{"class":69}," 'next'",[48,10914,678],{"class":62},[48,10916,656],{"class":332},[48,10918,683],{"class":54},[48,10920,171],{"class":332},[48,10922,629],{"class":54},[11,10924,10925,10927],{},[17,10926,10802],{}," is the gate. The other two are what the template switches on for which skeleton to show. They all get set together:",[39,10929,10931],{"className":1888,"code":10930,"language":1890,"meta":44,"style":44},"const fetchData = async (mode: 'init' | 'next') => {\n  if (inFlight.value !== null) return;\n  if (mode === 'next' && (!hasNextPage.value || isFetchingMore.value)) return;\n\n  inFlight.value = mode;\n\n  try {\n    if (mode === 'init') initialLoading.value = true;\n    if (mode === 'next') isFetchingMore.value = true;\n\n    const result = await runFetchHandler();\n    applyPaginationFromHandlerResult(result);\n    items.value = mode === 'init' ? result.items : [...items.value, ...result.items];\n  } finally {\n    if (mode === 'init') initialLoading.value = false;\n    if (mode === 'next') isFetchingMore.value = false;\n    inFlight.value = null;\n  }\n};\n",[17,10932,10933,10964,10981,11010,11014,11024,11028,11034,11053,11072,11076,11090,11098,11131,11139,11157,11175,11186,11190],{"__ignoreMap":44},[48,10934,10935,10937,10940,10942,10944,10946,10949,10951,10954,10956,10958,10960,10962],{"class":50,"line":51},[48,10936,329],{"class":62},[48,10938,10939],{"class":261}," fetchData",[48,10941,342],{"class":62},[48,10943,3340],{"class":62},[48,10945,2180],{"class":54},[48,10947,10948],{"class":600},"mode",[48,10950,336],{"class":62},[48,10952,10953],{"class":69}," 'init'",[48,10955,678],{"class":62},[48,10957,10912],{"class":69},[48,10959,806],{"class":54},[48,10961,647],{"class":62},[48,10963,1623],{"class":54},[48,10965,10966,10968,10971,10973,10975,10977,10979],{"class":50,"line":81},[48,10967,2450],{"class":62},[48,10969,10970],{"class":54}," (inFlight.value ",[48,10972,3809],{"class":62},[48,10974,656],{"class":332},[48,10976,806],{"class":54},[48,10978,2194],{"class":62},[48,10980,318],{"class":54},[48,10982,10983,10985,10988,10990,10992,10994,10996,10998,11001,11003,11006,11008],{"class":50,"line":87},[48,10984,2450],{"class":62},[48,10986,10987],{"class":54}," (mode ",[48,10989,653],{"class":62},[48,10991,10912],{"class":69},[48,10993,8707],{"class":62},[48,10995,2180],{"class":54},[48,10997,2455],{"class":62},[48,10999,11000],{"class":54},"hasNextPage.value ",[48,11002,2694],{"class":62},[48,11004,11005],{"class":54}," isFetchingMore.value)) ",[48,11007,2194],{"class":62},[48,11009,318],{"class":54},[48,11011,11012],{"class":50,"line":93},[48,11013,324],{"emptyLinePlaceholder":323},[48,11015,11016,11019,11021],{"class":50,"line":362},[48,11017,11018],{"class":54},"  inFlight.value ",[48,11020,66],{"class":62},[48,11022,11023],{"class":54}," mode;\n",[48,11025,11026],{"class":50,"line":373},[48,11027,324],{"emptyLinePlaceholder":323},[48,11029,11030,11032],{"class":50,"line":384},[48,11031,2479],{"class":62},[48,11033,1623],{"class":54},[48,11035,11036,11038,11040,11042,11044,11047,11049,11051],{"class":50,"line":661},[48,11037,2135],{"class":62},[48,11039,10987],{"class":54},[48,11041,653],{"class":62},[48,11043,10953],{"class":69},[48,11045,11046],{"class":54},") initialLoading.value ",[48,11048,66],{"class":62},[48,11050,3167],{"class":332},[48,11052,318],{"class":54},[48,11054,11055,11057,11059,11061,11063,11066,11068,11070],{"class":50,"line":690},[48,11056,2135],{"class":62},[48,11058,10987],{"class":54},[48,11060,653],{"class":62},[48,11062,10912],{"class":69},[48,11064,11065],{"class":54},") isFetchingMore.value ",[48,11067,66],{"class":62},[48,11069,3167],{"class":332},[48,11071,318],{"class":54},[48,11073,11074],{"class":50,"line":695},[48,11075,324],{"emptyLinePlaceholder":323},[48,11077,11078,11080,11082,11084,11086,11088],{"class":50,"line":722},[48,11079,2201],{"class":62},[48,11081,9503],{"class":332},[48,11083,342],{"class":62},[48,11085,3551],{"class":62},[48,11087,10489],{"class":261},[48,11089,3473],{"class":54},[48,11091,11092,11095],{"class":50,"line":743},[48,11093,11094],{"class":261},"    applyPaginationFromHandlerResult",[48,11096,11097],{"class":54},"(result);\n",[48,11099,11100,11103,11105,11108,11110,11112,11114,11117,11119,11121,11123,11126,11128],{"class":50,"line":767},[48,11101,11102],{"class":54},"    items.value ",[48,11104,66],{"class":62},[48,11106,11107],{"class":54}," mode ",[48,11109,653],{"class":62},[48,11111,10953],{"class":69},[48,11113,7621],{"class":62},[48,11115,11116],{"class":54}," result.items ",[48,11118,336],{"class":62},[48,11120,7432],{"class":54},[48,11122,9475],{"class":62},[48,11124,11125],{"class":54},"items.value, ",[48,11127,9475],{"class":62},[48,11129,11130],{"class":54},"result.items];\n",[48,11132,11133,11135,11137],{"class":50,"line":778},[48,11134,2575],{"class":54},[48,11136,4047],{"class":62},[48,11138,1623],{"class":54},[48,11140,11141,11143,11145,11147,11149,11151,11153,11155],{"class":50,"line":783},[48,11142,2135],{"class":62},[48,11144,10987],{"class":54},[48,11146,653],{"class":62},[48,11148,10953],{"class":69},[48,11150,11046],{"class":54},[48,11152,66],{"class":62},[48,11154,3295],{"class":332},[48,11156,318],{"class":54},[48,11158,11159,11161,11163,11165,11167,11169,11171,11173],{"class":50,"line":788},[48,11160,2135],{"class":62},[48,11162,10987],{"class":54},[48,11164,653],{"class":62},[48,11166,10912],{"class":69},[48,11168,11065],{"class":54},[48,11170,66],{"class":62},[48,11172,3295],{"class":332},[48,11174,318],{"class":54},[48,11176,11177,11180,11182,11184],{"class":50,"line":819},[48,11178,11179],{"class":54},"    inFlight.value ",[48,11181,66],{"class":62},[48,11183,656],{"class":332},[48,11185,318],{"class":54},[48,11187,11188],{"class":50,"line":828},[48,11189,2590],{"class":54},[48,11191,11192],{"class":50,"line":833},[48,11193,5737],{"class":54},[11,11195,11196,11197,11200,11201,11204,11205,11207,11208,11211],{},"Two small disciplines keep this honest. The early return checks ",[17,11198,11199],{},"inFlight.value !== null",", not any specific boolean, so if init is in flight and a next-page trigger fires, the next fetch is rejected even though ",[17,11202,11203],{},"isFetchingMore"," is still false. And the release lives in ",[17,11206,4047],{},", not ",[17,11209,11210],{},"try",". A fetch that throws still releases the gate, so the component can't silently lock itself into \"refusing to load anything\" after one network hiccup.",[1012,11213,11215],{"id":11214},"the-sentinel-and-the-06-threshold","The sentinel, and the 0.6 threshold",[11,11217,11218,11219,336],{},"The sentinel itself is a 1px div with ",[17,11220,11221],{},"aria-hidden",[39,11223,11225],{"className":41,"code":11224,"language":43,"meta":44,"style":44},"\u003Cdiv ref=\"sentinelRef\" class=\"h-1 min-w-[1px] w-full\" aria-hidden=\"true\" \u002F>\n",[17,11226,11227],{"__ignoreMap":44},[48,11228,11229,11231,11233,11235,11237,11240,11242,11244,11247,11250,11252,11255],{"class":50,"line":51},[48,11230,55],{"class":54},[48,11232,59],{"class":58},[48,11234,621],{"class":261},[48,11236,66],{"class":54},[48,11238,11239],{"class":69},"\"sentinelRef\"",[48,11241,998],{"class":261},[48,11243,66],{"class":54},[48,11245,11246],{"class":69},"\"h-1 min-w-[1px] w-full\"",[48,11248,11249],{"class":261}," aria-hidden",[48,11251,66],{"class":54},[48,11253,11254],{"class":69},"\"true\"",[48,11256,884],{"class":54},[11,11258,11259],{},"And the observer has two safeguards I want to isolate:",[39,11261,11263],{"className":1888,"code":11262,"language":1890,"meta":44,"style":44},"observer = new IntersectionObserver(\n  (entries) => {\n    const entry = entries[0];\n    if (!entry?.isIntersecting) return;\n    if (!initialLoading.value && !isFetchingMore.value && hasNextPage.value) {\n      void fetchNextPage();\n    }\n  },\n  { root: null, rootMargin: '0px', threshold: 0.6 }\n);\n",[17,11264,11265,11279,11291,11307,11322,11345,11355,11359,11363,11384],{"__ignoreMap":44},[48,11266,11267,11270,11272,11274,11277],{"class":50,"line":51},[48,11268,11269],{"class":54},"observer ",[48,11271,66],{"class":62},[48,11273,3832],{"class":62},[48,11275,11276],{"class":261}," IntersectionObserver",[48,11278,2111],{"class":54},[48,11280,11281,11283,11285,11287,11289],{"class":50,"line":81},[48,11282,2121],{"class":54},[48,11284,6056],{"class":600},[48,11286,806],{"class":54},[48,11288,647],{"class":62},[48,11290,1623],{"class":54},[48,11292,11293,11295,11298,11300,11303,11305],{"class":50,"line":87},[48,11294,2201],{"class":62},[48,11296,11297],{"class":332}," entry",[48,11299,342],{"class":62},[48,11301,11302],{"class":54}," entries[",[48,11304,2840],{"class":332},[48,11306,5939],{"class":54},[48,11308,11309,11311,11313,11315,11318,11320],{"class":50,"line":93},[48,11310,2135],{"class":62},[48,11312,2180],{"class":54},[48,11314,2455],{"class":62},[48,11316,11317],{"class":54},"entry?.isIntersecting) ",[48,11319,2194],{"class":62},[48,11321,318],{"class":54},[48,11323,11324,11326,11328,11330,11333,11335,11337,11340,11342],{"class":50,"line":362},[48,11325,2135],{"class":62},[48,11327,2180],{"class":54},[48,11329,2455],{"class":62},[48,11331,11332],{"class":54},"initialLoading.value ",[48,11334,2514],{"class":62},[48,11336,9421],{"class":62},[48,11338,11339],{"class":54},"isFetchingMore.value ",[48,11341,2514],{"class":62},[48,11343,11344],{"class":54}," hasNextPage.value) {\n",[48,11346,11347,11350,11353],{"class":50,"line":373},[48,11348,11349],{"class":62},"      void",[48,11351,11352],{"class":261}," fetchNextPage",[48,11354,3473],{"class":54},[48,11356,11357],{"class":50,"line":384},[48,11358,2173],{"class":54},[48,11360,11361],{"class":50,"line":661},[48,11362,2234],{"class":54},[48,11364,11365,11368,11370,11373,11376,11379,11382],{"class":50,"line":690},[48,11366,11367],{"class":54},"  { root: ",[48,11369,171],{"class":332},[48,11371,11372],{"class":54},", rootMargin: ",[48,11374,11375],{"class":69},"'0px'",[48,11377,11378],{"class":54},", threshold: ",[48,11380,11381],{"class":332},"0.6",[48,11383,2244],{"class":54},[48,11385,11386],{"class":50,"line":695},[48,11387,629],{"class":54},[11,11389,11390,10327,11393,11395,11396,11399,11400,11403,11404,526],{},[17,11391,11392],{},"threshold: 0.6",[17,11394,2840],{},". The sentinel is 1px; at ",[17,11397,11398],{},"threshold: 0",", jittering one pixel in and out of the viewport during a scroll can fire the callback multiple times. At 0.6, the intersection has to be meaningful (60% of a 1px target is not a lot, but it's stable against jitter). And the state check ",[2087,11401,11402],{},"inside"," the callback re-confirms the gate: between intersection firing and the callback running, another load might have started. The three-condition guard is the idempotent second line of defense behind ",[17,11405,10802],{},[25,11407,11409],{"id":11408},"resetting-when-the-fetchers-identity-changes","Resetting when the fetcher's identity changes",[11,11411,11412,11413,11416],{},"The user clicks \"Category: Shoes.\" The parent component swaps in a new ",[17,11414,11415],{},"fetchHandler"," that includes the new filter. What should happen: clear the list, fetch page 1 with the new handler, attach a new observer.",[11,11418,11419,11420,11423],{},"What happens if you don't handle it: the old list stays on screen. Page 2 of the ",[2087,11421,11422],{},"new"," filter gets appended to page 1 of the old filter. The user sees a mix of shoes and unrelated items, and \"page 3\" fetches happen against one handler but get interpreted against another's pagination state.",[11,11425,11426],{},"The trigger is a watch on the handler itself:",[39,11428,11430],{"className":1888,"code":11429,"language":1890,"meta":44,"style":44},"watch(\n  () => props.fetchHandler,\n  async () => {\n    observer?.disconnect();\n\n    items.value = [];\n    hasNextPage.value = false;\n    pagination.value = {\n      current_page: 1,\n      per_page: pagination.value.per_page,\n      total: 0,\n      last_page: 1,\n    };\n\n    await fetchData('init');\n    setupObserver();\n  }\n);\n",[17,11431,11432,11438,11447,11458,11468,11472,11481,11492,11501,11510,11515,11524,11533,11537,11541,11554,11561,11565],{"__ignoreMap":44},[48,11433,11434,11436],{"class":50,"line":51},[48,11435,2108],{"class":261},[48,11437,2111],{"class":54},[48,11439,11440,11442,11444],{"class":50,"line":81},[48,11441,3066],{"class":54},[48,11443,647],{"class":62},[48,11445,11446],{"class":54}," props.fetchHandler,\n",[48,11448,11449,11452,11454,11456],{"class":50,"line":87},[48,11450,11451],{"class":62},"  async",[48,11453,4427],{"class":54},[48,11455,647],{"class":62},[48,11457,1623],{"class":54},[48,11459,11460,11463,11466],{"class":50,"line":93},[48,11461,11462],{"class":54},"    observer?.",[48,11464,11465],{"class":261},"disconnect",[48,11467,3473],{"class":54},[48,11469,11470],{"class":50,"line":362},[48,11471,324],{"emptyLinePlaceholder":323},[48,11473,11474,11476,11478],{"class":50,"line":373},[48,11475,11102],{"class":54},[48,11477,66],{"class":62},[48,11479,11480],{"class":54}," [];\n",[48,11482,11483,11486,11488,11490],{"class":50,"line":384},[48,11484,11485],{"class":54},"    hasNextPage.value ",[48,11487,66],{"class":62},[48,11489,3295],{"class":332},[48,11491,318],{"class":54},[48,11493,11494,11497,11499],{"class":50,"line":661},[48,11495,11496],{"class":54},"    pagination.value ",[48,11498,66],{"class":62},[48,11500,1623],{"class":54},[48,11502,11503,11506,11508],{"class":50,"line":690},[48,11504,11505],{"class":54},"      current_page: ",[48,11507,2082],{"class":332},[48,11509,359],{"class":54},[48,11511,11512],{"class":50,"line":695},[48,11513,11514],{"class":54},"      per_page: pagination.value.per_page,\n",[48,11516,11517,11520,11522],{"class":50,"line":722},[48,11518,11519],{"class":54},"      total: ",[48,11521,2840],{"class":332},[48,11523,359],{"class":54},[48,11525,11526,11529,11531],{"class":50,"line":743},[48,11527,11528],{"class":54},"      last_page: ",[48,11530,2082],{"class":332},[48,11532,359],{"class":54},[48,11534,11535],{"class":50,"line":767},[48,11536,6546],{"class":54},[48,11538,11539],{"class":50,"line":778},[48,11540,324],{"emptyLinePlaceholder":323},[48,11542,11543,11546,11548,11550,11552],{"class":50,"line":783},[48,11544,11545],{"class":62},"    await",[48,11547,10939],{"class":261},[48,11549,624],{"class":54},[48,11551,10907],{"class":69},[48,11553,629],{"class":54},[48,11555,11556,11559],{"class":50,"line":788},[48,11557,11558],{"class":261},"    setupObserver",[48,11560,3473],{"class":54},[48,11562,11563],{"class":50,"line":819},[48,11564,2590],{"class":54},[48,11566,11567],{"class":50,"line":828},[48,11568,629],{"class":54},[11,11570,11571],{},"Four things happen, in order, and all four are load-bearing.",[11,11573,11574,11575,11578,11579,11582],{},"Disconnect first. If you don't, the old observer is still alive during the reset, and the sentinel is still intersecting the viewport (nothing scrolled). The old observer can fire ",[17,11576,11577],{},"fetchNextPage()"," against stale pagination state in the middle of the reset, which means a request goes out with ",[17,11580,11581],{},"current_page: 2"," against a handler that thinks it's returning the first page. That request is fundamentally wrong. It has to be prevented, not corrected.",[11,11584,11585,11586,11589],{},"Clear items and pagination next. Keep ",[17,11587,11588],{},"per_page"," because that's often a user preference the filter shouldn't reset. The other three fields go back to defaults.",[11,11591,11592,11593,11596],{},"Fire ",[17,11594,11595],{},"fetchData('init')",". Skeleton shows. Fetch runs with the new handler. Items arrive.",[11,11598,11599],{},"Re-attach the observer. The DOM node is the same one; we're binding a fresh observer instance to the post-reset state.",[11,11601,11602,11603,11606,11607,11610,11611,11614],{},"There's a subtlety here worth writing down. The watch is on ",[17,11604,11605],{},"props.fetchHandler",": reference identity. If the consumer passes ",[17,11608,11609],{},"() => $api('\u002Fproducts', { query: { category } })"," inline in the template, a new function is created on every parent re-render, and the watch fires on every re-render. That's almost never what you want. Consumers have to be disciplined: memoize the handler with ",[17,11612,11613],{},"computed",", or pull it out of a composable whose identity is stable, or accept that the list will re-fetch on every parent render.",[11,11616,11617,11618,11620],{},"I considered having the component accept a separate ",[17,11619,6872],{}," prop to trigger resets explicitly instead of using reference identity, but that pushes complexity to every consumer. The reference-watch contract is simpler if you're willing to educate people about function identity once.",[25,11622,11624],{"id":11623},"observer-cleanup-and-the-leak-it-prevents","Observer cleanup, and the leak it prevents",[11,11626,11627,11628,11630,11631,11633,11634,11636,11637,158,11639,11641],{},"This is the quiet one. A user navigates away from a route that had ",[17,11629,10335],{}," on it. Vue unmounts the component. But the IntersectionObserver, if you didn't clean it up, is still alive. It holds a reference to the sentinel DOM node ",[2087,11632,7692],{}," to the callback closure, which captured the entire ",[17,11635,9935],{}," scope: ",[17,11638,10037],{},[17,11640,10099],{},", all the refs, the whole component instance.",[11,11643,11644],{},"None of that gets garbage-collected until the observer releases them. On a route with lots of in-and-out traffic, the leak compounds. I found one in a previous app after a user reported the page getting slower the longer they used it. Their session was accumulating dozens of dead components' worth of state.",[11,11646,11647],{},"The fix is one call:",[39,11649,11651],{"className":1888,"code":11650,"language":1890,"meta":44,"style":44},"onBeforeUnmount(() => {\n  observer?.disconnect();\n  observer = null;\n});\n",[17,11652,11653,11664,11673,11684],{"__ignoreMap":44},[48,11654,11655,11658,11660,11662],{"class":50,"line":51},[48,11656,11657],{"class":261},"onBeforeUnmount",[48,11659,644],{"class":54},[48,11661,647],{"class":62},[48,11663,1623],{"class":54},[48,11665,11666,11669,11671],{"class":50,"line":81},[48,11667,11668],{"class":54},"  observer?.",[48,11670,11465],{"class":261},[48,11672,3473],{"class":54},[48,11674,11675,11678,11680,11682],{"class":50,"line":87},[48,11676,11677],{"class":54},"  observer ",[48,11679,66],{"class":62},[48,11681,656],{"class":332},[48,11683,318],{"class":54},[48,11685,11686],{"class":50,"line":93},[48,11687,387],{"class":54},[11,11689,11690,11691,158,11694,158,11697,158,11700,158,11703,11705,11706,3722,11708,11711],{},"Easily written, easily forgotten. The thing that catches this in code review is a mental checklist: every ",[17,11692,11693],{},"new IntersectionObserver",[17,11695,11696],{},"new MutationObserver",[17,11698,11699],{},"addEventListener",[17,11701,11702],{},"setInterval",[17,11704,1227],{}," needs a matching teardown in ",[17,11707,11657],{},[17,11709,11710],{},"onUnmounted",". If you can't point to the teardown, you're leaking. Every time.",[1012,11713,11715,11718],{"id":11714},"v-scroll-reveal-per-card-observer",[17,11716,11717],{},"v-scroll-reveal",": per-card observer",[11,11720,11721],{},"The per-card scroll-reveal directive inside the same component has its own version of this:",[39,11723,11725],{"className":1888,"code":11724,"language":1890,"meta":44,"style":44},"const vScrollReveal = {\n  mounted(el: HTMLElement) {\n    \u002F\u002F ... setup observer, stash on el._scrollObserver\n    observer.observe(el);\n  },\n  unmounted(el: HTMLElement) {\n    const observer = (el as HTMLElement & { _scrollObserver?: IntersectionObserver })._scrollObserver;\n    observer?.disconnect();\n  },\n} as const;\n",[17,11726,11727,11738,11755,11760,11771,11775,11790,11821,11829,11833],{"__ignoreMap":44},[48,11728,11729,11731,11734,11736],{"class":50,"line":51},[48,11730,329],{"class":62},[48,11732,11733],{"class":332}," vScrollReveal",[48,11735,342],{"class":62},[48,11737,1623],{"class":54},[48,11739,11740,11743,11745,11748,11750,11753],{"class":50,"line":81},[48,11741,11742],{"class":261},"  mounted",[48,11744,624],{"class":54},[48,11746,11747],{"class":600},"el",[48,11749,336],{"class":62},[48,11751,11752],{"class":261}," HTMLElement",[48,11754,2146],{"class":54},[48,11756,11757],{"class":50,"line":87},[48,11758,11759],{"class":2473},"    \u002F\u002F ... setup observer, stash on el._scrollObserver\n",[48,11761,11762,11765,11768],{"class":50,"line":93},[48,11763,11764],{"class":54},"    observer.",[48,11766,11767],{"class":261},"observe",[48,11769,11770],{"class":54},"(el);\n",[48,11772,11773],{"class":50,"line":362},[48,11774,2234],{"class":54},[48,11776,11777,11780,11782,11784,11786,11788],{"class":50,"line":373},[48,11778,11779],{"class":261},"  unmounted",[48,11781,624],{"class":54},[48,11783,11747],{"class":600},[48,11785,336],{"class":62},[48,11787,11752],{"class":261},[48,11789,2146],{"class":54},[48,11791,11792,11794,11797,11799,11802,11804,11806,11809,11811,11814,11816,11818],{"class":50,"line":384},[48,11793,2201],{"class":62},[48,11795,11796],{"class":332}," observer",[48,11798,342],{"class":62},[48,11800,11801],{"class":54}," (el ",[48,11803,2658],{"class":62},[48,11805,11752],{"class":261},[48,11807,11808],{"class":62}," &",[48,11810,700],{"class":54},[48,11812,11813],{"class":600},"_scrollObserver",[48,11815,3940],{"class":62},[48,11817,11276],{"class":261},[48,11819,11820],{"class":54}," })._scrollObserver;\n",[48,11822,11823,11825,11827],{"class":50,"line":661},[48,11824,11462],{"class":54},[48,11826,11465],{"class":261},[48,11828,3473],{"class":54},[48,11830,11831],{"class":50,"line":690},[48,11832,2234],{"class":54},[48,11834,11835,11838,11840,11843],{"class":50,"line":695},[48,11836,11837],{"class":54},"} ",[48,11839,2658],{"class":62},[48,11841,11842],{"class":62}," const",[48,11844,318],{"class":54},[11,11846,11847,11848,11851,11852,11855,11856,11859,11860,11862],{},"The directive stashes its observer on the element itself (",[17,11849,11850],{},"el._scrollObserver",") so ",[17,11853,11854],{},"unmounted"," can find it later. There's also a ",[17,11857,11858],{},"observer.unobserve(el)"," call inside the mount-time callback. Once a card has faded in, there's nothing left to watch for, and leaving the observer attached is just more work per scroll event for no payoff. ",[17,11861,11854],{}," is the belt-and-braces fallback for cards that were unmounted before they were ever visible (scrolled past fast, navigated away, removed by a filter).",[11,11864,11865,11866,11868,11869,11872],{},"The type assertion on ",[17,11867,11813],{}," is uglier than I'd like. A ",[17,11870,11871],{},"Symbol"," key would avoid the cast but would also break DevTools inspection. I picked the version I can actually read.",[25,11874,11876,11877],{"id":11875},"type-guards-without-zod","Type guards without ",[17,11878,11879],{},"zod",[11,11881,11882],{},"Two things sit outside the bug frame but earned their way into the component.",[11,11884,11885],{},"The type guards for pagination metadata:",[39,11887,11889],{"className":1888,"code":11888,"language":1890,"meta":44,"style":44},"function isRecord(value: unknown): value is Record\u003Cstring, unknown> {\n  return typeof value === 'object' && value !== null;\n}\nfunction isNumber(value: unknown): value is number {\n  return typeof value === 'number' && Number.isFinite(value);\n}\nfunction isPaginationMeta(value: unknown): value is ISPPaginationMeta {\n  if (!isRecord(value)) return false;\n  return (\n    isNumber(value.current_page) &&\n    isNumber(value.per_page) &&\n    isNumber(value.total) &&\n    isNumber(value.last_page)\n  );\n}\n",[17,11890,11891,11927,11950,11954,11981,12003,12007,12034,12054,12060,12071,12080,12089,12096,12100],{"__ignoreMap":44},[48,11892,11893,11895,11898,11900,11902,11904,11906,11908,11910,11913,11915,11917,11919,11921,11923,11925],{"class":50,"line":51},[48,11894,2419],{"class":62},[48,11896,11897],{"class":261}," isRecord",[48,11899,624],{"class":54},[48,11901,4878],{"class":600},[48,11903,336],{"class":62},[48,11905,4432],{"class":332},[48,11907,2434],{"class":54},[48,11909,336],{"class":62},[48,11911,11912],{"class":600}," value",[48,11914,6261],{"class":62},[48,11916,5444],{"class":261},[48,11918,55],{"class":54},[48,11920,1909],{"class":332},[48,11922,158],{"class":54},[48,11924,6651],{"class":332},[48,11926,3373],{"class":54},[48,11928,11929,11931,11933,11936,11938,11940,11942,11944,11946,11948],{"class":50,"line":81},[48,11930,2963],{"class":62},[48,11932,3803],{"class":62},[48,11934,11935],{"class":54}," value ",[48,11937,653],{"class":62},[48,11939,8020],{"class":69},[48,11941,8707],{"class":62},[48,11943,11935],{"class":54},[48,11945,3809],{"class":62},[48,11947,656],{"class":332},[48,11949,318],{"class":54},[48,11951,11952],{"class":50,"line":87},[48,11953,1717],{"class":54},[48,11955,11956,11958,11961,11963,11965,11967,11969,11971,11973,11975,11977,11979],{"class":50,"line":93},[48,11957,2419],{"class":62},[48,11959,11960],{"class":261}," isNumber",[48,11962,624],{"class":54},[48,11964,4878],{"class":600},[48,11966,336],{"class":62},[48,11968,4432],{"class":332},[48,11970,2434],{"class":54},[48,11972,336],{"class":62},[48,11974,11912],{"class":600},[48,11976,6261],{"class":62},[48,11978,3769],{"class":332},[48,11980,1623],{"class":54},[48,11982,11983,11985,11987,11989,11991,11994,11996,11998,12000],{"class":50,"line":362},[48,11984,2963],{"class":62},[48,11986,3803],{"class":62},[48,11988,11935],{"class":54},[48,11990,653],{"class":62},[48,11992,11993],{"class":69}," 'number'",[48,11995,8707],{"class":62},[48,11997,6126],{"class":54},[48,11999,6129],{"class":261},[48,12001,12002],{"class":54},"(value);\n",[48,12004,12005],{"class":50,"line":373},[48,12006,1717],{"class":54},[48,12008,12009,12011,12014,12016,12018,12020,12022,12024,12026,12028,12030,12032],{"class":50,"line":384},[48,12010,2419],{"class":62},[48,12012,12013],{"class":261}," isPaginationMeta",[48,12015,624],{"class":54},[48,12017,4878],{"class":600},[48,12019,336],{"class":62},[48,12021,4432],{"class":332},[48,12023,2434],{"class":54},[48,12025,336],{"class":62},[48,12027,11912],{"class":600},[48,12029,6261],{"class":62},[48,12031,10750],{"class":261},[48,12033,1623],{"class":54},[48,12035,12036,12038,12040,12042,12045,12048,12050,12052],{"class":50,"line":661},[48,12037,2450],{"class":62},[48,12039,2180],{"class":54},[48,12041,2455],{"class":62},[48,12043,12044],{"class":261},"isRecord",[48,12046,12047],{"class":54},"(value)) ",[48,12049,2194],{"class":62},[48,12051,3295],{"class":332},[48,12053,318],{"class":54},[48,12055,12056,12058],{"class":50,"line":690},[48,12057,2963],{"class":62},[48,12059,10540],{"class":54},[48,12061,12062,12065,12068],{"class":50,"line":695},[48,12063,12064],{"class":261},"    isNumber",[48,12066,12067],{"class":54},"(value.current_page) ",[48,12069,12070],{"class":62},"&&\n",[48,12072,12073,12075,12078],{"class":50,"line":722},[48,12074,12064],{"class":261},[48,12076,12077],{"class":54},"(value.per_page) ",[48,12079,12070],{"class":62},[48,12081,12082,12084,12087],{"class":50,"line":743},[48,12083,12064],{"class":261},[48,12085,12086],{"class":54},"(value.total) ",[48,12088,12070],{"class":62},[48,12090,12091,12093],{"class":50,"line":767},[48,12092,12064],{"class":261},[48,12094,12095],{"class":54},"(value.last_page)\n",[48,12097,12098],{"class":50,"line":778},[48,12099,7474],{"class":54},[48,12101,12102],{"class":50,"line":783},[48,12103,1717],{"class":54},[11,12105,12106,12107,12109,12110,12113,12114,12117,12118,1294,12121,12124,12125,12128],{},"No ",[17,12108,11879],{},", no ",[17,12111,12112],{},"yup",". Three functions, thirty lines, no dependency added to a UI component. The point isn't performance; it's that a UI component that depends on a validator library leaks that choice into every consumer. Hand-rolled guards keep the dependency graph clean, and for this shape of check (four fields, all numbers) they're shorter than a schema anyway. The ",[17,12115,12116],{},"Number.isFinite"," check matters. Without it, ",[17,12119,12120],{},"NaN",[17,12122,12123],{},"Infinity"," pass ",[17,12126,12127],{},"typeof === 'number'"," and you end up with \"page 3 of Infinity\" behaviour downstream.",[11,12130,2251,12131,12134,12135,12138],{},[17,12132,12133],{},"void fetchNextPage()"," in the observer callback is tiny and deliberate. The callback isn't async, so awaiting does nothing. Floating promises trip most linters. ",[17,12136,12137],{},"void"," is the honest signal: \"I know this is async, I'm intentionally not awaiting, yes I've thought about it.\" It's one keyword, and it's the difference between a clean lint and a suppress-comment.",[25,12140,12142],{"id":12141},"a-summary-of-the-four-defenses","A summary of the four defenses",[11,12144,12145,12146,12148],{},"At ~230 lines, the whole component is the kind of thing you could rewrite in an afternoon. What makes it work isn't any of the individual pieces (arity dispatch, ",[17,12147,10802],{}," as a discriminated state, observer cleanup, reference-identity resets, hand-rolled type guards). Each is small enough that if I explained it in isolation you'd nod and move on.",[11,12150,12151,12152,12155],{},"The thing that made this component stop being a bug factory was seeing the four failure modes above and writing ",[2087,12153,12154],{},"specific"," guards for each one. No library, no abstraction. Just a handful of small defenses, each paid for by a bug I shipped in an earlier version. That's most of what \"hardening\" looks like in practice: not cleverness, just a longer memory.",[218,12157,12158],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":44,"searchDepth":81,"depth":81,"links":12160},[12161,12162,12164,12168,12169,12173,12175],{"id":10346,"depth":81,"text":10347},{"id":10450,"depth":81,"text":12163},"fetchHandler.length: arity-based dispatch",{"id":10799,"depth":81,"text":12165,"children":12166},"inFlight: one gate, three derived readouts",[12167],{"id":11214,"depth":87,"text":11215},{"id":11408,"depth":81,"text":11409},{"id":11623,"depth":81,"text":11624,"children":12170},[12171],{"id":11714,"depth":87,"text":12172},"v-scroll-reveal: per-card observer",{"id":11875,"depth":81,"text":12174},"Type guards without zod",{"id":12141,"depth":81,"text":12142},"2026-02-28","The four bugs every infinite-scroll list eventually ships, and the specific lines inside a 230-line Vue component that prevent each one.","أربع مشكلات تظهر عاجلاً أم آجلاً في أيّ قائمة infinite-scroll، والأسطر المحدَّدة داخل مكوّن Vue من 230 سطراً تُعالج كلاً منها.",[12180,1839,12181,4070,10099,12182,12183,1845,1840],"infinite scroll","IntersectionObserver","arity dispatch","memory leak",{},"\u002Fwrites\u002Finside-asentinel-pagination",{"title":10315,"description":12177},"writes\u002Finside-asentinel-pagination","أربع مشكلات في أيّ قائمة infinite-scroll، وطريقة إصلاحها","A4QPVYj70KuO1iIpcC0zJuv5Ohm9pcsJPI1sUfOyLoI",{"id":12191,"title":12192,"body":12193,"date":13893,"description":13894,"description_ar":13895,"draft":1835,"extension":1836,"image":1837,"keywords":13896,"lang":1848,"meta":13908,"navigation":323,"path":13909,"seo":13910,"stem":13911,"title_ar":13912,"updatedAt":1837,"__hash__":13913},"writes\u002Fwrites\u002Fbuilding-ali-nuxt-toolkit.md","Building ali-nuxt-toolkit, a tour of the internals",{"type":8,"value":12194,"toc":13870},[12195,12201,12204,12208,12213,12247,12250,12276,12279,12283,12286,12556,12571,12579,12587,12598,12695,12702,12709,12712,12750,12753,12854,12861,12865,12887,12922,12925,12929,12932,12996,13003,13021,13028,13033,13183,13201,13208,13211,13321,13328,13392,13399,13406,13409,13416,13570,13587,13594,13694,13701,13705,13708,13716,13719,13723,13733,13736,13772,13776,13787,13795,13810,13814,13817,13857,13867],[11,12196,12197,12198,526],{},"A while back I decided to pull the patterns I kept rewriting at work into a small set of Nuxt modules. Nothing novel, just the stuff that everyone on every SaaS team eventually writes: a typed HTTP client, layout-scoped middleware, and a crypto service for locally-stored secrets. Packaging them properly turned into its own project: ",[35,12199,12200],{},"ali-nuxt-toolkit",[11,12202,12203],{},"This post is a tour of what's inside and why certain pieces are shaped the way they are. I'll skip the obvious parts and spend most of the words on the details I'd want to read if someone else had written it.",[25,12205,12207],{"id":12206},"the-shape-of-the-repo","The shape of the repo",[11,12209,12210,12212],{},[17,12211,12200],{}," is a pnpm monorepo. The top level is roughly:",[8453,12214,12215,12225,12235,12241],{},[3669,12216,12217,12220,12221,12224],{},[17,12218,12219],{},"packages\u002F",": three independently published modules under the ",[17,12222,12223],{},"@alikhalilll"," scope.",[3669,12226,12227,12230,12231,12234],{},[17,12228,12229],{},"apps\u002Fdocs\u002F",": a Nuxt 4 + ",[17,12232,12233],{},"@nuxt\u002Fcontent"," site, prerendered to static HTML.",[3669,12236,12237,12240],{},[17,12238,12239],{},"playgrounds\u002Fnuxt\u002F",": a minimal app that wires all three modules together; handy for kicking the tires locally.",[3669,12242,12243,12246],{},[17,12244,12245],{},".github\u002Fworkflows\u002F",": CI (lint, typecheck, matrix build on Node 20 + 22) and a Changesets-driven release pipeline.",[11,12248,12249],{},"The three packages:",[8453,12251,12252,12260,12268],{},[3669,12253,12254,12259],{},[35,12255,12256],{},[17,12257,12258],{},"@alikhalilll\u002Fnuxt-api-provider",": strongly-typed fetch client with an interceptor chain, retry\u002Fbackoff, timeouts, and upload\u002Fdownload progress.",[3669,12261,12262,12267],{},[35,12263,12264],{},[17,12265,12266],{},"@alikhalilll\u002Fnuxt-auto-middleware",": layout-scoped route middleware with glob patterns, named groups, and per-page overrides.",[3669,12269,12270,12275],{},[35,12271,12272],{},[17,12273,12274],{},"@alikhalilll\u002Fnuxt-crypto",": AES-256-GCM + PBKDF2 built on Web Crypto, with an LRU key cache and pluggable algorithms.",[11,12277,12278],{},"They're deliberately small and focused. Each one works standalone. Each one also has a framework-agnostic \"core\" that can run in Node, Bun, Deno, or a test. No Nuxt required.",[25,12280,12282],{"id":12281},"the-module-skeleton","The module skeleton",[11,12284,12285],{},"All three modules follow the same Nuxt 4 shape:",[39,12287,12289],{"className":1888,"code":12288,"language":1890,"meta":44,"style":44},"export default defineNuxtModule\u003COptions>({\n  meta: {\n    name,\n    configKey,\n    compatibility: { nuxt: '>=3.0.0' },\n  },\n  defaults: { \u002F* ... *\u002F },\n  setup(options, nuxt) {\n    \u002F\u002F 1. Write a serialized config file into .nuxt\n    addTemplate({\n      filename: 'my-module-config.mjs',\n      getContents: () => `export default ${JSON.stringify(config)};\\n`,\n    });\n\n    \u002F\u002F 2. Register the runtime plugin\n    addPlugin({ src: resolver.resolve('.\u002Fruntime\u002Fplugin'), mode: 'all' });\n\n    \u002F\u002F 3. Augment Nuxt's types so $myModule shows up everywhere\n    const typesTemplate = addTemplate({\n      filename: 'types\u002Fmy-module.d.ts',\n      getContents: () => typeDeclarations,\n    });\n    nuxt.hook('prepare:types', ({ references }) => {\n      references.push({ path: typesTemplate.dst });\n    });\n  },\n});\n",[17,12290,12291,12309,12314,12319,12324,12334,12338,12348,12364,12369,12376,12386,12424,12429,12433,12438,12461,12465,12470,12484,12493,12504,12508,12533,12544,12548,12552],{"__ignoreMap":44},[48,12292,12293,12295,12298,12301,12303,12306],{"class":50,"line":51},[48,12294,3337],{"class":62},[48,12296,12297],{"class":62}," default",[48,12299,12300],{"class":261}," defineNuxtModule",[48,12302,55],{"class":54},[48,12304,12305],{"class":261},"Options",[48,12307,12308],{"class":54},">({\n",[48,12310,12311],{"class":50,"line":81},[48,12312,12313],{"class":54},"  meta: {\n",[48,12315,12316],{"class":50,"line":87},[48,12317,12318],{"class":54},"    name,\n",[48,12320,12321],{"class":50,"line":93},[48,12322,12323],{"class":54},"    configKey,\n",[48,12325,12326,12329,12332],{"class":50,"line":362},[48,12327,12328],{"class":54},"    compatibility: { nuxt: ",[48,12330,12331],{"class":69},"'>=3.0.0'",[48,12333,7411],{"class":54},[48,12335,12336],{"class":50,"line":373},[48,12337,2234],{"class":54},[48,12339,12340,12343,12346],{"class":50,"line":384},[48,12341,12342],{"class":54},"  defaults: { ",[48,12344,12345],{"class":2473},"\u002F* ... *\u002F",[48,12347,7411],{"class":54},[48,12349,12350,12353,12355,12357,12359,12362],{"class":50,"line":661},[48,12351,12352],{"class":261},"  setup",[48,12354,624],{"class":54},[48,12356,5404],{"class":600},[48,12358,158],{"class":54},[48,12360,12361],{"class":600},"nuxt",[48,12363,2146],{"class":54},[48,12365,12366],{"class":50,"line":690},[48,12367,12368],{"class":2473},"    \u002F\u002F 1. Write a serialized config file into .nuxt\n",[48,12370,12371,12374],{"class":50,"line":695},[48,12372,12373],{"class":261},"    addTemplate",[48,12375,719],{"class":54},[48,12377,12378,12381,12384],{"class":50,"line":722},[48,12379,12380],{"class":54},"      filename: ",[48,12382,12383],{"class":69},"'my-module-config.mjs'",[48,12385,359],{"class":54},[48,12387,12388,12391,12393,12395,12398,12401,12403,12406,12408,12411,12413,12416,12419,12422],{"class":50,"line":743},[48,12389,12390],{"class":261},"      getContents",[48,12392,749],{"class":54},[48,12394,647],{"class":62},[48,12396,12397],{"class":69}," `export default ${",[48,12399,12400],{"class":332},"JSON",[48,12402,526],{"class":69},[48,12404,12405],{"class":261},"stringify",[48,12407,624],{"class":69},[48,12409,12410],{"class":54},"config",[48,12412,2434],{"class":69},[48,12414,12415],{"class":69},"};",[48,12417,12418],{"class":332},"\\n",[48,12420,12421],{"class":69},"`",[48,12423,359],{"class":54},[48,12425,12426],{"class":50,"line":767},[48,12427,12428],{"class":54},"    });\n",[48,12430,12431],{"class":50,"line":778},[48,12432,324],{"emptyLinePlaceholder":323},[48,12434,12435],{"class":50,"line":783},[48,12436,12437],{"class":2473},"    \u002F\u002F 2. Register the runtime plugin\n",[48,12439,12440,12443,12446,12448,12450,12453,12456,12459],{"class":50,"line":788},[48,12441,12442],{"class":261},"    addPlugin",[48,12444,12445],{"class":54},"({ src: resolver.",[48,12447,4229],{"class":261},[48,12449,624],{"class":54},[48,12451,12452],{"class":69},"'.\u002Fruntime\u002Fplugin'",[48,12454,12455],{"class":54},"), mode: ",[48,12457,12458],{"class":69},"'all'",[48,12460,1922],{"class":54},[48,12462,12463],{"class":50,"line":819},[48,12464,324],{"emptyLinePlaceholder":323},[48,12466,12467],{"class":50,"line":828},[48,12468,12469],{"class":2473},"    \u002F\u002F 3. Augment Nuxt's types so $myModule shows up everywhere\n",[48,12471,12472,12474,12477,12479,12482],{"class":50,"line":833},[48,12473,2201],{"class":62},[48,12475,12476],{"class":332}," typesTemplate",[48,12478,342],{"class":62},[48,12480,12481],{"class":261}," addTemplate",[48,12483,719],{"class":54},[48,12485,12486,12488,12491],{"class":50,"line":843},[48,12487,12380],{"class":54},[48,12489,12490],{"class":69},"'types\u002Fmy-module.d.ts'",[48,12492,359],{"class":54},[48,12494,12495,12497,12499,12501],{"class":50,"line":860},[48,12496,12390],{"class":261},[48,12498,749],{"class":54},[48,12500,647],{"class":62},[48,12502,12503],{"class":54}," typeDeclarations,\n",[48,12505,12506],{"class":50,"line":887},[48,12507,12428],{"class":54},[48,12509,12510,12513,12516,12518,12521,12524,12527,12529,12531],{"class":50,"line":905},[48,12511,12512],{"class":54},"    nuxt.",[48,12514,12515],{"class":261},"hook",[48,12517,624],{"class":54},[48,12519,12520],{"class":69},"'prepare:types'",[48,12522,12523],{"class":54},", ({ ",[48,12525,12526],{"class":600},"references",[48,12528,4480],{"class":54},[48,12530,647],{"class":62},[48,12532,1623],{"class":54},[48,12534,12535,12538,12541],{"class":50,"line":926},[48,12536,12537],{"class":54},"      references.",[48,12539,12540],{"class":261},"push",[48,12542,12543],{"class":54},"({ path: typesTemplate.dst });\n",[48,12545,12546],{"class":50,"line":936},[48,12547,12428],{"class":54},[48,12549,12550],{"class":50,"line":2726},[48,12551,2234],{"class":54},[48,12553,12554],{"class":50,"line":2732},[48,12555,387],{"class":54},[11,12557,12558,12559,12562,12563,12566,12567,12570],{},"The interesting part is ",[2087,12560,12561],{},"what I'm not doing",". I'm not passing the config object through provide\u002Finject at runtime, and I'm not importing user code directly from ",[17,12564,12565],{},"module.ts",". Everything flows through generated files in ",[17,12568,12569],{},".nuxt",". This has two benefits:",[3666,12572,12573,12576],{},[3669,12574,12575],{},"The runtime plugin stays tiny. It just imports a plain JS object from a virtual path. No work at boot.",[3669,12577,12578],{},"Tree-shaking works. If a feature isn't used, its template contents aren't referenced, and the bundle drops it.",[1012,12580,12582,12583,12586],{"id":12581},"virtual-modules-and-how-to-keep-tsc-happy","Virtual modules, and how to keep ",[17,12584,12585],{},"tsc"," happy",[11,12588,12589,12590,12593,12594,12597],{},"Generated templates don't exist on disk when the type-checker runs. Without extra work, ",[17,12591,12592],{},"import config from '#build\u002Fapi-provider-config.mjs'"," would be flagged as missing. Each package has a ",[17,12595,12596],{},"nuxt-virtual.d.ts"," declaring stubs:",[39,12599,12601],{"className":1888,"code":12600,"language":1890,"meta":44,"style":44},"declare module '#build\u002Fapi-provider-config.mjs' {\n  const config: {\n    baseURL: string;\n    defaultTimeoutMs: number;\n    retry: { attempts: number; baseDelayMs: number };\n  };\n  export default config;\n}\n",[17,12602,12603,12616,12627,12638,12649,12676,12681,12691],{"__ignoreMap":44},[48,12604,12605,12608,12611,12614],{"class":50,"line":51},[48,12606,12607],{"class":62},"declare",[48,12609,12610],{"class":62}," module",[48,12612,12613],{"class":69}," '#build\u002Fapi-provider-config.mjs'",[48,12615,1623],{"class":54},[48,12617,12618,12620,12623,12625],{"class":50,"line":81},[48,12619,2604],{"class":62},[48,12621,12622],{"class":332}," config",[48,12624,336],{"class":62},[48,12626,1623],{"class":54},[48,12628,12629,12632,12634,12636],{"class":50,"line":87},[48,12630,12631],{"class":600},"    baseURL",[48,12633,336],{"class":62},[48,12635,606],{"class":332},[48,12637,318],{"class":54},[48,12639,12640,12643,12645,12647],{"class":50,"line":93},[48,12641,12642],{"class":600},"    defaultTimeoutMs",[48,12644,336],{"class":62},[48,12646,3769],{"class":332},[48,12648,318],{"class":54},[48,12650,12651,12654,12656,12658,12661,12663,12665,12667,12670,12672,12674],{"class":50,"line":362},[48,12652,12653],{"class":600},"    retry",[48,12655,336],{"class":62},[48,12657,700],{"class":54},[48,12659,12660],{"class":600},"attempts",[48,12662,336],{"class":62},[48,12664,3769],{"class":332},[48,12666,3945],{"class":54},[48,12668,12669],{"class":600},"baseDelayMs",[48,12671,336],{"class":62},[48,12673,3769],{"class":332},[48,12675,3954],{"class":54},[48,12677,12678],{"class":50,"line":373},[48,12679,12680],{"class":54},"  };\n",[48,12682,12683,12686,12688],{"class":50,"line":384},[48,12684,12685],{"class":62},"  export",[48,12687,12297],{"class":62},[48,12689,12690],{"class":54}," config;\n",[48,12692,12693],{"class":50,"line":661},[48,12694,1717],{"class":54},[11,12696,12697,12698,12701],{},"Now ",[17,12699,12700],{},"tsc --noEmit"," passes, and editor autocomplete still works on fields of the generated config.",[25,12703,12705,12708],{"id":12704},"nuxt-api-provider-chainable-interceptors-and-two-transports",[17,12706,12707],{},"nuxt-api-provider",": chainable interceptors and two transports",[11,12710,12711],{},"The public surface of the API client is deliberately flat. You call it like a function:",[39,12713,12715],{"className":1888,"code":12714,"language":1890,"meta":44,"style":44},"const users = await $apiProvider\u003CUser[]>('\u002Fusers', { method: 'GET' });\n",[17,12716,12717],{"__ignoreMap":44},[48,12718,12719,12721,12724,12726,12728,12731,12733,12736,12739,12742,12745,12748],{"class":50,"line":51},[48,12720,329],{"class":62},[48,12722,12723],{"class":332}," users",[48,12725,342],{"class":62},[48,12727,3551],{"class":62},[48,12729,12730],{"class":261}," $apiProvider",[48,12732,55],{"class":54},[48,12734,12735],{"class":261},"User",[48,12737,12738],{"class":54},"[]>(",[48,12740,12741],{"class":69},"'\u002Fusers'",[48,12743,12744],{"class":54},", { method: ",[48,12746,12747],{"class":69},"'GET'",[48,12749,1922],{"class":54},[11,12751,12752],{},"You add cross-cutting behavior through three chains:",[39,12754,12756],{"className":1888,"code":12755,"language":1890,"meta":44,"style":44},"$apiProvider.useRequest((ctx) => {\n  ctx.headers.Authorization = `Bearer ${token}`;\n});\n\n$apiProvider.useResponse((ctx, response) => { \u002F* ... *\u002F });\n$apiProvider.useError((ctx, err) => { \u002F* ... *\u002F });\n",[17,12757,12758,12777,12794,12798,12802,12828],{"__ignoreMap":44},[48,12759,12760,12763,12766,12768,12771,12773,12775],{"class":50,"line":51},[48,12761,12762],{"class":54},"$apiProvider.",[48,12764,12765],{"class":261},"useRequest",[48,12767,800],{"class":54},[48,12769,12770],{"class":600},"ctx",[48,12772,806],{"class":54},[48,12774,647],{"class":62},[48,12776,1623],{"class":54},[48,12778,12779,12782,12784,12787,12790,12792],{"class":50,"line":81},[48,12780,12781],{"class":54},"  ctx.headers.Authorization ",[48,12783,66],{"class":62},[48,12785,12786],{"class":69}," `Bearer ${",[48,12788,12789],{"class":54},"token",[48,12791,738],{"class":69},[48,12793,318],{"class":54},[48,12795,12796],{"class":50,"line":87},[48,12797,387],{"class":54},[48,12799,12800],{"class":50,"line":93},[48,12801,324],{"emptyLinePlaceholder":323},[48,12803,12804,12806,12809,12811,12813,12815,12818,12820,12822,12824,12826],{"class":50,"line":362},[48,12805,12762],{"class":54},[48,12807,12808],{"class":261},"useResponse",[48,12810,800],{"class":54},[48,12812,12770],{"class":600},[48,12814,158],{"class":54},[48,12816,12817],{"class":600},"response",[48,12819,806],{"class":54},[48,12821,647],{"class":62},[48,12823,700],{"class":54},[48,12825,12345],{"class":2473},[48,12827,1922],{"class":54},[48,12829,12830,12832,12835,12837,12839,12841,12844,12846,12848,12850,12852],{"class":50,"line":373},[48,12831,12762],{"class":54},[48,12833,12834],{"class":261},"useError",[48,12836,800],{"class":54},[48,12838,12770],{"class":600},[48,12840,158],{"class":54},[48,12842,12843],{"class":600},"err",[48,12845,806],{"class":54},[48,12847,647],{"class":62},[48,12849,700],{"class":54},[48,12851,12345],{"class":2473},[48,12853,1922],{"class":54},[11,12855,12856,12857,12860],{},"Each ",[17,12858,12859],{},"use*"," returns an unsubscribe function, which matters if you register interceptors from a component and want to clean up on unmount.",[1012,12862,12864],{"id":12863},"two-transports-one-api","Two transports, one API",[11,12866,12867,12868,12871,12872,12874,12875,12878,12879,12882,12883,12886],{},"Most requests go through ",[17,12869,12870],{},"fetch",". But ",[17,12873,12870],{}," doesn't expose upload progress. The ",[17,12876,12877],{},"ReadableStream"," side of the Request body is fine for streams but browsers don't give you byte-level ",[17,12880,12881],{},"progress"," events the way XHR does. So when a caller passes ",[17,12884,12885],{},"onRequestProgress",", the client swaps transports:",[39,12888,12890],{"className":1888,"code":12889,"language":1890,"meta":44,"style":44},"const transport = ctx.options.onRequestProgress\n  ? createXhrFetch(ctx.options.onRequestProgress)\n  : defaultFetch;\n",[17,12891,12892,12904,12915],{"__ignoreMap":44},[48,12893,12894,12896,12899,12901],{"class":50,"line":51},[48,12895,329],{"class":62},[48,12897,12898],{"class":332}," transport",[48,12900,342],{"class":62},[48,12902,12903],{"class":54}," ctx.options.onRequestProgress\n",[48,12905,12906,12909,12912],{"class":50,"line":81},[48,12907,12908],{"class":62},"  ?",[48,12910,12911],{"class":261}," createXhrFetch",[48,12913,12914],{"class":54},"(ctx.options.onRequestProgress)\n",[48,12916,12917,12919],{"class":50,"line":87},[48,12918,4207],{"class":62},[48,12920,12921],{"class":54}," defaultFetch;\n",[11,12923,12924],{},"The XHR wrapper returns a Response-shaped object so the rest of the pipeline doesn't care how the bytes came back. That kind of \"one API, swap the engine under it\" has been my favorite pattern for two years. It keeps optional features optional without branching the whole code path.",[1012,12926,12928],{"id":12927},"interceptors-by-path-not-by-function","Interceptors by path, not by function",[11,12930,12931],{},"The module options look like this:",[39,12933,12935],{"className":1888,"code":12934,"language":1890,"meta":44,"style":44},"{\n  baseURL: 'https:\u002F\u002Fapi.example.com',\n  onRequestPath:  '~\u002Fapi\u002Fon-request.ts',\n  onSuccessPath:  '~\u002Fapi\u002Fon-response.ts',\n  onErrorPath:    '~\u002Fapi\u002Fon-error.ts',\n}\n",[17,12936,12937,12942,12954,12967,12979,12992],{"__ignoreMap":44},[48,12938,12939],{"class":50,"line":51},[48,12940,12941],{"class":54},"{\n",[48,12943,12944,12947,12949,12952],{"class":50,"line":81},[48,12945,12946],{"class":261},"  baseURL",[48,12948,1631],{"class":54},[48,12950,12951],{"class":69},"'https:\u002F\u002Fapi.example.com'",[48,12953,359],{"class":54},[48,12955,12956,12959,12962,12965],{"class":50,"line":87},[48,12957,12958],{"class":261},"  onRequestPath",[48,12960,12961],{"class":54},":  ",[48,12963,12964],{"class":69},"'~\u002Fapi\u002Fon-request.ts'",[48,12966,359],{"class":54},[48,12968,12969,12972,12974,12977],{"class":50,"line":93},[48,12970,12971],{"class":261},"  onSuccessPath",[48,12973,12961],{"class":54},[48,12975,12976],{"class":69},"'~\u002Fapi\u002Fon-response.ts'",[48,12978,359],{"class":54},[48,12980,12981,12984,12987,12990],{"class":50,"line":362},[48,12982,12983],{"class":261},"  onErrorPath",[48,12985,12986],{"class":54},":    ",[48,12988,12989],{"class":69},"'~\u002Fapi\u002Fon-error.ts'",[48,12991,359],{"class":54},[48,12993,12994],{"class":50,"line":373},[48,12995,1717],{"class":54},[11,12997,12998,12999,13002],{},"Interceptors are resolved as ",[2087,13000,13001],{},"file paths",", not inline functions. The generated template dynamically imports them, and the runtime plugin wires whatever's exported into the chain. Two reasons:",[8453,13004,13005,13015],{},[3669,13006,13007,13010,13011,13014],{},[35,13008,13009],{},"No circular config."," Users often want to import types from the api-provider module inside their interceptor. If the interceptor lived inside ",[17,13012,13013],{},"nuxt.config.ts",", the config file would depend on the module it's configuring.",[3669,13016,13017,13020],{},[35,13018,13019],{},"Code-splitting."," The interceptor becomes its own chunk, which matters on cold loads.",[1012,13022,13024,13025],{"id":13023},"error-branding-without-instanceof","Error branding without ",[17,13026,13027],{},"instanceof",[11,13029,13030,13032],{},[17,13031,13027],{}," fails the moment you have two copies of the same class. This happens with duplicated deps, iframes, web workers, or pnpm hoisting quirks. The client's error type uses a Symbol brand instead:",[39,13034,13036],{"className":1888,"code":13035,"language":1890,"meta":44,"style":44},"const API_ERROR_BRAND: unique symbol = Symbol.for(\n  '@alikhalilll\u002Fnuxt-api-provider.ApiError'\n);\n\nexport class ApiError extends Error {\n  readonly [API_ERROR_BRAND] = true;\n\n  static is(e: unknown): e is ApiError {\n    return typeof e === 'object' && e !== null && API_ERROR_BRAND in e;\n  }\n}\n",[17,13037,13038,13063,13068,13072,13076,13093,13112,13116,13145,13175,13179],{"__ignoreMap":44},[48,13039,13040,13042,13045,13047,13050,13053,13055,13058,13061],{"class":50,"line":51},[48,13041,329],{"class":62},[48,13043,13044],{"class":332}," API_ERROR_BRAND",[48,13046,336],{"class":62},[48,13048,13049],{"class":261}," unique",[48,13051,13052],{"class":332}," symbol",[48,13054,342],{"class":62},[48,13056,13057],{"class":54}," Symbol.",[48,13059,13060],{"class":261},"for",[48,13062,2111],{"class":54},[48,13064,13065],{"class":50,"line":81},[48,13066,13067],{"class":69},"  '@alikhalilll\u002Fnuxt-api-provider.ApiError'\n",[48,13069,13070],{"class":50,"line":87},[48,13071,629],{"class":54},[48,13073,13074],{"class":50,"line":93},[48,13075,324],{"emptyLinePlaceholder":323},[48,13077,13078,13080,13082,13085,13088,13091],{"class":50,"line":362},[48,13079,3337],{"class":62},[48,13081,998],{"class":62},[48,13083,13084],{"class":261}," ApiError",[48,13086,13087],{"class":62}," extends",[48,13089,13090],{"class":261}," Error",[48,13092,1623],{"class":54},[48,13094,13095,13098,13100,13103,13106,13108,13110],{"class":50,"line":373},[48,13096,13097],{"class":62},"  readonly",[48,13099,7432],{"class":54},[48,13101,13102],{"class":332},"API_ERROR_BRAND",[48,13104,13105],{"class":54},"] ",[48,13107,66],{"class":62},[48,13109,3167],{"class":332},[48,13111,318],{"class":54},[48,13113,13114],{"class":50,"line":384},[48,13115,324],{"emptyLinePlaceholder":323},[48,13117,13118,13121,13123,13125,13128,13130,13132,13134,13136,13139,13141,13143],{"class":50,"line":661},[48,13119,13120],{"class":62},"  static",[48,13122,6261],{"class":261},[48,13124,624],{"class":54},[48,13126,13127],{"class":600},"e",[48,13129,336],{"class":62},[48,13131,4432],{"class":332},[48,13133,2434],{"class":54},[48,13135,336],{"class":62},[48,13137,13138],{"class":600}," e",[48,13140,6261],{"class":62},[48,13142,13084],{"class":261},[48,13144,1623],{"class":54},[48,13146,13147,13149,13151,13154,13156,13158,13160,13162,13164,13166,13168,13170,13172],{"class":50,"line":690},[48,13148,2946],{"class":62},[48,13150,3803],{"class":62},[48,13152,13153],{"class":54}," e ",[48,13155,653],{"class":62},[48,13157,8020],{"class":69},[48,13159,8707],{"class":62},[48,13161,13153],{"class":54},[48,13163,3809],{"class":62},[48,13165,656],{"class":332},[48,13167,8707],{"class":62},[48,13169,13044],{"class":332},[48,13171,8234],{"class":62},[48,13173,13174],{"class":54}," e;\n",[48,13176,13177],{"class":50,"line":695},[48,13178,2590],{"class":54},[48,13180,13181],{"class":50,"line":722},[48,13182,1717],{"class":54},[11,13184,13185,13188,13189,13192,13193,13196,13197,13200],{},[17,13186,13187],{},"Symbol.for"," gives you the ",[2087,13190,13191],{},"same"," symbol across module copies. ",[17,13194,13195],{},"ApiError.is(err)"," works where ",[17,13198,13199],{},"err instanceof ApiError"," doesn't. This has saved me on every project that ever crossed a realm boundary.",[25,13202,13204,13207],{"id":13203},"nuxt-auto-middleware-compile-time-regex-runtime-dispatch",[17,13205,13206],{},"nuxt-auto-middleware",": compile-time regex, runtime dispatch",[11,13209,13210],{},"The module takes rules like this:",[39,13212,13214],{"className":1888,"code":13213,"language":1890,"meta":44,"style":44},"autoMiddleware: {\n  groups: {\n    adminOnly: ['auth', 'require-admin'],\n  },\n  rules: [\n    { layouts: ['admin-*'], middlewares: ['@adminOnly'] },\n    { layouts: [\u002F^workspace\\\u002F.*\u002F], middlewares: ['auth', 'workspace'] },\n  ],\n}\n",[17,13215,13216,13224,13231,13250,13254,13262,13279,13312,13317],{"__ignoreMap":44},[48,13217,13218,13221],{"class":50,"line":51},[48,13219,13220],{"class":261},"autoMiddleware",[48,13222,13223],{"class":54},": {\n",[48,13225,13226,13229],{"class":50,"line":81},[48,13227,13228],{"class":261},"  groups",[48,13230,13223],{"class":54},[48,13232,13233,13236,13239,13242,13244,13247],{"class":50,"line":87},[48,13234,13235],{"class":261},"    adminOnly",[48,13237,13238],{"class":54},": [",[48,13240,13241],{"class":69},"'auth'",[48,13243,158],{"class":54},[48,13245,13246],{"class":69},"'require-admin'",[48,13248,13249],{"class":54},"],\n",[48,13251,13252],{"class":50,"line":93},[48,13253,2234],{"class":54},[48,13255,13256,13259],{"class":50,"line":362},[48,13257,13258],{"class":261},"  rules",[48,13260,13261],{"class":54},": [\n",[48,13263,13264,13267,13270,13273,13276],{"class":50,"line":373},[48,13265,13266],{"class":54},"    { layouts: [",[48,13268,13269],{"class":69},"'admin-*'",[48,13271,13272],{"class":54},"], middlewares: [",[48,13274,13275],{"class":69},"'@adminOnly'",[48,13277,13278],{"class":54},"] },\n",[48,13280,13281,13283,13285,13287,13291,13294,13296,13299,13301,13303,13305,13307,13310],{"class":50,"line":384},[48,13282,13266],{"class":54},[48,13284,4005],{"class":69},[48,13286,3996],{"class":62},[48,13288,13290],{"class":13289},"sA_wV","workspace",[48,13292,13293],{"class":5879},"\\\u002F",[48,13295,526],{"class":332},[48,13297,13298],{"class":62},"*",[48,13300,4005],{"class":69},[48,13302,13272],{"class":54},[48,13304,13241],{"class":69},[48,13306,158],{"class":54},[48,13308,13309],{"class":69},"'workspace'",[48,13311,13278],{"class":54},[48,13313,13314],{"class":50,"line":661},[48,13315,13316],{"class":54},"  ],\n",[48,13318,13319],{"class":50,"line":690},[48,13320,1717],{"class":54},[11,13322,13323,13324,13327],{},"At module setup time, each glob gets compiled to a RegExp. Each group reference gets expanded. The resulting rules get ",[2087,13325,13326],{},"serialized"," into a generated template:",[39,13329,13331],{"className":1888,"code":13330,"language":1890,"meta":44,"style":44},"export const rules = [\n  { patterns: ['^admin-.*$'], middlewares: ['auth', 'require-admin'] },\n  { patterns: ['^workspace\\\\\u002F.*'], middlewares: ['auth', 'workspace'] },\n];\n",[17,13332,13333,13347,13365,13388],{"__ignoreMap":44},[48,13334,13335,13337,13339,13342,13344],{"class":50,"line":51},[48,13336,3337],{"class":62},[48,13338,11842],{"class":62},[48,13340,13341],{"class":332}," rules",[48,13343,342],{"class":62},[48,13345,13346],{"class":54}," [\n",[48,13348,13349,13352,13355,13357,13359,13361,13363],{"class":50,"line":81},[48,13350,13351],{"class":54},"  { patterns: [",[48,13353,13354],{"class":69},"'^admin-.*$'",[48,13356,13272],{"class":54},[48,13358,13241],{"class":69},[48,13360,158],{"class":54},[48,13362,13246],{"class":69},[48,13364,13278],{"class":54},[48,13366,13367,13369,13372,13375,13378,13380,13382,13384,13386],{"class":50,"line":87},[48,13368,13351],{"class":54},[48,13370,13371],{"class":69},"'^workspace",[48,13373,13374],{"class":332},"\\\\",[48,13376,13377],{"class":69},"\u002F.*'",[48,13379,13272],{"class":54},[48,13381,13241],{"class":69},[48,13383,158],{"class":54},[48,13385,13309],{"class":69},[48,13387,13278],{"class":54},[48,13389,13390],{"class":50,"line":93},[48,13391,5939],{"class":54},[11,13393,13394,13395,13398],{},"At runtime, the plugin rehydrates the patterns with ",[17,13396,13397],{},"new RegExp(source)"," and matches against the current layout. The client never sees a glob parser; it's been pre-compiled away. On a typical app this saves a few KB, but more importantly it means adding more rules doesn't cost more bundle size beyond the rule strings themselves.",[25,13400,13402,13405],{"id":13401},"nuxt-crypto-the-lru-that-caches-promises",[17,13403,13404],{},"nuxt-crypto",": the LRU that caches promises",[11,13407,13408],{},"The crypto service is the part I'm happiest with. It wraps Web Crypto's AES-GCM + PBKDF2 primitives with a clean encrypt\u002Fdecrypt API. The trick is the key cache.",[11,13410,13411,13412,13415],{},"PBKDF2 with 100,000 iterations is slow on purpose; that's the whole point. But when your UI tries to decrypt three fields from IndexedDB in parallel, doing three separate key derivations is both wasteful and slow. The cache fixes that, but the detail that actually matters is ",[2087,13413,13414],{},"what"," it caches:",[39,13417,13419],{"className":1888,"code":13418,"language":1890,"meta":44,"style":44},"const getDerivedKey = async (salt, fingerprint?) => {\n  const key = KeyCache.key(salt, iterations, fingerprint);\n  const cached = cache.get(key);\n  if (cached) return cached;\n\n  \u002F\u002F Cache the promise, not the settled key\n  const pending = algorithm.deriveKey({\n    subtle,\n    passphrase,\n    fingerprint,\n    salt,\n    iterations,\n  });\n  cache.set(key, pending);\n  return pending;\n};\n",[17,13420,13421,13450,13467,13484,13494,13498,13503,13520,13525,13530,13535,13540,13545,13549,13559,13566],{"__ignoreMap":44},[48,13422,13423,13425,13428,13430,13432,13434,13437,13439,13442,13444,13446,13448],{"class":50,"line":51},[48,13424,329],{"class":62},[48,13426,13427],{"class":261}," getDerivedKey",[48,13429,342],{"class":62},[48,13431,3340],{"class":62},[48,13433,2180],{"class":54},[48,13435,13436],{"class":600},"salt",[48,13438,158],{"class":54},[48,13440,13441],{"class":600},"fingerprint",[48,13443,757],{"class":62},[48,13445,806],{"class":54},[48,13447,647],{"class":62},[48,13449,1623],{"class":54},[48,13451,13452,13454,13457,13459,13462,13464],{"class":50,"line":81},[48,13453,2604],{"class":62},[48,13455,13456],{"class":332}," key",[48,13458,342],{"class":62},[48,13460,13461],{"class":54}," KeyCache.",[48,13463,6872],{"class":261},[48,13465,13466],{"class":54},"(salt, iterations, fingerprint);\n",[48,13468,13469,13471,13473,13475,13478,13481],{"class":50,"line":87},[48,13470,2604],{"class":62},[48,13472,3465],{"class":332},[48,13474,342],{"class":62},[48,13476,13477],{"class":54}," cache.",[48,13479,13480],{"class":261},"get",[48,13482,13483],{"class":54},"(key);\n",[48,13485,13486,13488,13490,13492],{"class":50,"line":93},[48,13487,2450],{"class":62},[48,13489,3480],{"class":54},[48,13491,2194],{"class":62},[48,13493,3485],{"class":54},[48,13495,13496],{"class":50,"line":362},[48,13497,324],{"emptyLinePlaceholder":323},[48,13499,13500],{"class":50,"line":373},[48,13501,13502],{"class":2473},"  \u002F\u002F Cache the promise, not the settled key\n",[48,13504,13505,13507,13510,13512,13515,13518],{"class":50,"line":384},[48,13506,2604],{"class":62},[48,13508,13509],{"class":332}," pending",[48,13511,342],{"class":62},[48,13513,13514],{"class":54}," algorithm.",[48,13516,13517],{"class":261},"deriveKey",[48,13519,719],{"class":54},[48,13521,13522],{"class":50,"line":661},[48,13523,13524],{"class":54},"    subtle,\n",[48,13526,13527],{"class":50,"line":690},[48,13528,13529],{"class":54},"    passphrase,\n",[48,13531,13532],{"class":50,"line":695},[48,13533,13534],{"class":54},"    fingerprint,\n",[48,13536,13537],{"class":50,"line":722},[48,13538,13539],{"class":54},"    salt,\n",[48,13541,13542],{"class":50,"line":743},[48,13543,13544],{"class":54},"    iterations,\n",[48,13546,13547],{"class":50,"line":767},[48,13548,7813],{"class":54},[48,13550,13551,13554,13556],{"class":50,"line":778},[48,13552,13553],{"class":54},"  cache.",[48,13555,8603],{"class":261},[48,13557,13558],{"class":54},"(key, pending);\n",[48,13560,13561,13563],{"class":50,"line":783},[48,13562,2963],{"class":62},[48,13564,13565],{"class":54}," pending;\n",[48,13567,13568],{"class":50,"line":788},[48,13569,5737],{"class":54},[11,13571,13572,13573,11207,13576,13579,13580,13583,13584,13586],{},"The cache holds ",[17,13574,13575],{},"Promise\u003CCryptoKey>",[17,13577,13578],{},"CryptoKey",". If three ",[17,13581,13582],{},"decrypt()"," calls arrive in the same tick with the same salt, they all await the ",[2087,13585,13191],{}," pending promise. PBKDF2 runs once. The naive version (cache the settled key) leaves a window where two calls both see \"not cached\" and start duplicate work.",[11,13588,13589,13590,13593],{},"The LRU itself uses the fact that JavaScript ",[17,13591,13592],{},"Map"," preserves insertion order:",[39,13595,13597],{"className":1888,"code":13596,"language":1890,"meta":44,"style":44},"get(key: string): Promise\u003CCryptoKey> | undefined {\n  const value = this.map.get(key);\n  if (!value) return undefined;\n  this.map.delete(key);   \u002F\u002F delete + re-insert moves to newest\n  this.map.set(key, value);\n  return value;\n}\n",[17,13598,13599,13621,13639,13656,13672,13683,13690],{"__ignoreMap":44},[48,13600,13601,13603,13606,13609,13611,13613,13615,13617,13619],{"class":50,"line":51},[48,13602,13480],{"class":261},[48,13604,13605],{"class":54},"(key: string): ",[48,13607,13608],{"class":332},"Promise",[48,13610,55],{"class":62},[48,13612,13578],{"class":54},[48,13614,5432],{"class":62},[48,13616,678],{"class":62},[48,13618,5429],{"class":332},[48,13620,1623],{"class":54},[48,13622,13623,13625,13627,13629,13632,13635,13637],{"class":50,"line":81},[48,13624,2604],{"class":62},[48,13626,11912],{"class":332},[48,13628,342],{"class":62},[48,13630,13631],{"class":332}," this",[48,13633,13634],{"class":54},".map.",[48,13636,13480],{"class":261},[48,13638,13483],{"class":54},[48,13640,13641,13643,13645,13647,13650,13652,13654],{"class":50,"line":87},[48,13642,2450],{"class":62},[48,13644,2180],{"class":54},[48,13646,2455],{"class":62},[48,13648,13649],{"class":54},"value) ",[48,13651,2194],{"class":62},[48,13653,5429],{"class":332},[48,13655,318],{"class":54},[48,13657,13658,13661,13663,13666,13669],{"class":50,"line":93},[48,13659,13660],{"class":332},"  this",[48,13662,13634],{"class":54},[48,13664,13665],{"class":261},"delete",[48,13667,13668],{"class":54},"(key);   ",[48,13670,13671],{"class":2473},"\u002F\u002F delete + re-insert moves to newest\n",[48,13673,13674,13676,13678,13680],{"class":50,"line":362},[48,13675,13660],{"class":332},[48,13677,13634],{"class":54},[48,13679,8603],{"class":261},[48,13681,13682],{"class":54},"(key, value);\n",[48,13684,13685,13687],{"class":50,"line":373},[48,13686,2963],{"class":62},[48,13688,13689],{"class":54}," value;\n",[48,13691,13692],{"class":50,"line":384},[48,13693,1717],{"class":54},[11,13695,13696,13697,13700],{},"Eviction is then just ",[17,13698,13699],{},"this.map.keys().next().value",": the oldest.",[1012,13702,13704],{"id":13703},"versioned-payloads","Versioned payloads",[11,13706,13707],{},"Encrypt output carries a version byte:",[39,13709,13714],{"className":13710,"code":13712,"language":13713},[13711],"language-text","[version: 1B] [salt: 16B] [iv: 12B] [ciphertext: ...]\n","text",[17,13715,13712],{"__ignoreMap":44},[11,13717,13718],{},"On decrypt, mismatched versions fail early. This is the kind of thing you add before you need it. The day I want to rotate from AES-GCM to something post-quantum, the old payloads will still decrypt through the old algorithm while new ones use the new one. Without a version byte you'd be stuck guessing.",[25,13720,13722],{"id":13721},"the-docs-site-as-a-playground-for-the-modules","The docs site as a playground for the modules",[11,13724,13725,13728,13729,13732],{},[17,13726,13727],{},"apps\u002Fdocs"," uses all three modules. That sounds obvious, but it's load-bearing: the docs are ",[2087,13730,13731],{},"how I notice breakage"," before users do. If an update to api-provider breaks the doc site's JSON Placeholder demo, CI fails on the build step. No test suite needed for that particular class of regression.",[11,13734,13735],{},"Other things that earned their keep:",[8453,13737,13738,13750,13764],{},[3669,13739,13740,13745,13746,13749],{},[35,13741,13742],{},[17,13743,13744],{},"@tailwindcss\u002Fvite",": no PostCSS, no ",[17,13747,13748],{},"tailwind.config.js",", just a Vite plugin. One less config file in my life.",[3669,13751,13752,13755,13756,13759,13760,13763],{},[35,13753,13754],{},"Static prerender to GitHub Pages",": Nitro's ",[17,13757,13758],{},"github-pages"," preset gives you a ",[17,13761,13762],{},"\u002F.output\u002Fpublic\u002F"," that Actions uploads straight to Pages. No servers, no cache invalidation, no surprises.",[3669,13765,13766,13771],{},[35,13767,13768],{},[17,13769,13770],{},"NUXT_PUBLIC_CF_ANALYTICS_TOKEN",": optional Cloudflare Web Analytics. No cookies, no banner, no opinion.",[25,13773,13775],{"id":13774},"release-changesets-all-the-way-down","Release: Changesets all the way down",[11,13777,13778,13779,13782,13783,13786],{},"Every PR that changes a package includes a ",[17,13780,13781],{},".changeset\u002F\u003Cid>.md"," describing the bump and changelog entry. On master, a GitHub Action runs ",[17,13784,13785],{},"changesets version"," and either:",[3666,13788,13789,13792],{},[3669,13790,13791],{},"Opens a \"Version Packages\" PR with the bumped versions, or",[3669,13793,13794],{},"Publishes to npm if versions are already bumped.",[11,13796,13797,13798,13801,13802,13805,13806,13809],{},"This is the least-effort setup I've found that gives honest changelogs, semver discipline, and no-touch npm publishing. The CI pipeline itself is three files: ",[17,13799,13800],{},"ci.yml"," (lint + typecheck + matrix build), ",[17,13803,13804],{},"release.yml"," (changesets), and ",[17,13807,13808],{},"commitlint.yml"," (conventional commits on PRs).",[25,13811,13813],{"id":13812},"the-parts-id-reuse-tomorrow","The parts I'd reuse tomorrow",[11,13815,13816],{},"If I were starting a new Nuxt module from scratch, I'd lift these patterns without thinking:",[8453,13818,13819,13829,13839,13845,13851],{},[3669,13820,13821,13828],{},[35,13822,13823,13824,13827],{},"Serialize config into a generated ",[17,13825,13826],{},".mjs"," file."," Avoids re-evaluating at runtime, and the runtime plugin stays 15 lines.",[3669,13830,13831,13838],{},[35,13832,13833,13834,13837],{},"Virtual-module ",[17,13835,13836],{},".d.ts"," stubs."," Offline typecheck without running the Nuxt prepare step.",[3669,13840,13841,13844],{},[35,13842,13843],{},"Symbol-branded errors."," Free for low-traffic modules, life-saving for the ones that cross realms.",[3669,13846,13847,13850],{},[35,13848,13849],{},"Promise-keyed caches."," Any time the underlying operation is async and expensive, cache the promise, not the result.",[3669,13852,13853,13856],{},[35,13854,13855],{},"Framework-agnostic core + thin Nuxt wrapper."," Makes the code easier to test, easier to reuse, and easier to delete.",[11,13858,13859,13860,13866],{},"The repo is MIT-licensed and lives on ",[9485,13861,13865],{"href":13862,"rel":13863},"https:\u002F\u002Fgithub.com\u002Falikhalilll",[13864],"nofollow","GitHub",". If you spot something that could be better, open an issue. I'd genuinely rather be corrected than comfortable.",[218,13868,13869],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}html pre.shiki code .snhLl, html code.shiki .snhLl{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}",{"title":44,"searchDepth":81,"depth":81,"links":13871},[13872,13873,13877,13884,13886,13890,13891,13892],{"id":12206,"depth":81,"text":12207},{"id":12281,"depth":81,"text":12282,"children":13874},[13875],{"id":12581,"depth":87,"text":13876},"Virtual modules, and how to keep tsc happy",{"id":12704,"depth":81,"text":13878,"children":13879},"nuxt-api-provider: chainable interceptors and two transports",[13880,13881,13882],{"id":12863,"depth":87,"text":12864},{"id":12927,"depth":87,"text":12928},{"id":13023,"depth":87,"text":13883},"Error branding without instanceof",{"id":13203,"depth":81,"text":13885},"nuxt-auto-middleware: compile-time regex, runtime dispatch",{"id":13401,"depth":81,"text":13887,"children":13888},"nuxt-crypto: the LRU that caches promises",[13889],{"id":13703,"depth":87,"text":13704},{"id":13721,"depth":81,"text":13722},{"id":13774,"depth":81,"text":13775},{"id":13812,"depth":81,"text":13813},"2026-01-12","Three Nuxt 4 modules in a pnpm monorepo. A typed fetch client with upload progress, AES-GCM and PBKDF2 crypto, and layout-scoped middleware.","ثلاث موديولات Nuxt 4 داخل monorepo مبني بـ pnpm. عميل fetch مكتوب بأنواع صارمة يدعم تقدّم الرفع، وتشفير AES-GCM مع PBKDF2، وطبقة middleware مربوطة بالـ layouts.",[13897,13898,1845,13899,13900,13901,13902,13903,13904,13905,13906,13907],"Nuxt 4","Nuxt modules","pnpm monorepo","fetch client","interceptors","upload progress","AES-GCM","PBKDF2","Web Crypto","route middleware","Changesets",{},"\u002Fwrites\u002Fbuilding-ali-nuxt-toolkit",{"title":12192,"description":13894},"writes\u002Fbuilding-ali-nuxt-toolkit","بناء ali-nuxt-toolkit، جولة داخل التفاصيل","Imy2c4cxZ1y_-_8kGo-d99wxcoSFFI0jHuoVychEQMc",{"id":13915,"title":13916,"body":13917,"date":18763,"description":18764,"description_ar":18765,"draft":1835,"extension":1836,"image":1837,"keywords":18766,"lang":1848,"meta":18773,"navigation":323,"path":18774,"seo":18775,"stem":18776,"title_ar":18777,"updatedAt":18763,"__hash__":18778},"writes\u002Fwrites\u002Fupload-and-download-progress-in-the-browser.md","A custom fetch wrapper, and XHR for upload progress",{"type":8,"value":13918,"toc":18739},[13919,13935,13953,13963,13966,13986,13993,13999,14002,14026,14042,14062,14085,14116,14129,14137,14141,14156,14159,14163,14166,14287,14290,14293,14552,14566,14573,14578,14620,14623,14805,14825,14829,14832,15379,15384,15387,15455,15458,15530,15534,15537,15740,15747,15751,15754,15883,15888,15891,15898,15904,16065,16071,16091,16094,16098,16101,16248,16251,16272,16285,16298,16308,16317,16376,16406,16409,16514,16522,16526,16529,16922,16925,16967,16971,16977,17313,17336,17354,17360,17369,17381,17384,18169,18172,18184,18203,18411,18432,18465,18469,18484,18563,18576,18594,18598,18601,18614,18628,18639,18654,18671,18675,18678,18733,18736],[11,13920,13921,13922,13924,13925,13927,13928,4005,13931,13934],{},"Anytime I need to talk to an HTTP API from the browser, I reach for ",[17,13923,12870],{}," first. It's the right primitive: Promise-first, composable with ",[17,13926,4070],{},", shares ",[17,13929,13930],{},"Request",[17,13932,13933],{},"Response"," with Service Workers and the Cache API, and the same signature runs in Node 18+, Bun, Deno, and Cloudflare Workers without an adapter. Most \"which HTTP client should I use?\" conversations end there.",[11,13936,13937,13938,13940,13941,13943,13944,13946,13947,13949,13950,13952],{},"But ",[17,13939,12870],{}," has one gap that nobody warns you about, and it's not the kind of gap someone's going to fix. It's structural. ",[17,13942,12870],{}," doesn't emit upload progress events. The ",[17,13945,13930],{}," body can be a ",[17,13948,12877],{},", but browsers don't fire ",[17,13951,12881],{}," as they drain bytes out of it onto the wire. You hand the body to the network stack and it goes.",[11,13954,13955,13956,13959,13960,526],{},"For a real upload progress bar (not a fake indeterminate spinner) you need ",[17,13957,13958],{},"XMLHttpRequest",". It's the older API, but it's the only one that exposes ",[17,13961,13962],{},"xhr.upload.onprogress",[11,13964,13965],{},"This post covers three things, in order:",[3666,13967,13968,13974,13980],{},[3669,13969,13970,13971,13973],{},"Why native ",[17,13972,12870],{}," earns its default status, and the specific benefits you're getting from it.",[3669,13975,13976,13977,13979],{},"How to build a custom fetch wrapper (defaults, timeouts, interceptors, retry) without losing ",[17,13978,12870],{},"'s shape.",[3669,13981,13982,13983,13985],{},"The XHR gap, the progress event map, and a small XHR-backed wrapper that returns a ",[17,13984,13933],{}," so the rest of your code stays fetch-shaped.",[11,13987,13988,13989,13992],{},"If you've ever wondered why so many production codebases have a ",[17,13990,13991],{},"createClient()"," factory, the answer mostly lives in parts 1 and 2. Part 3 is the single thing that client still can't do on its own.",[25,13994,13970,13996,13998],{"id":13995},"why-native-fetch-is-the-default-primitive",[17,13997,12870],{}," is the default primitive",[11,14000,14001],{},"Every HTTP-client debate I've been in eventually converges on \"use fetch.\" The reasons are worth stating outright. They're what you'd be giving up by defaulting to a different library, and they compound once you start wrapping it.",[11,14003,14004,8473,14007,14010,14011,14014,14015,14018,14019,14022,14023,14025],{},[35,14005,14006],{},"One Promise, one Response.",[17,14008,14009],{},"fetch(url, init)"," returns a ",[17,14012,14013],{},"Promise\u003CResponse>",". No state machine, no ",[17,14016,14017],{},"readyState",", no event-handler wiring for the happy path. ",[17,14020,14021],{},"await fetch(...)"," reads like every other async call in your codebase. XHR is still event-driven under the hood; ",[17,14024,12870],{}," is the Promise-shaped interface.",[11,14027,14028,14031,14032,14034,14035,14037,14038,14041],{},[35,14029,14030],{},"Unified Request\u002FResponse primitives."," The ",[17,14033,13933],{}," you get from ",[17,14036,12870],{}," is the same type Service Workers return, the Cache API stores, and edge runtimes accept. You can ",[17,14039,14040],{},".clone()"," a response, hand it back to the browser, cache it, or pipe it elsewhere: no translation layer.",[11,14043,14044,14049,14050,14053,14054,14057,14058,14061],{},[35,14045,14046,14047,526],{},"Cancellation via ",[17,14048,4070],{}," One signal can cancel a whole chain of work: a fetch, a downstream computation, a retry that hasn't fired yet. ",[17,14051,14052],{},"AbortSignal.any([...])"," composes multiple signals into one; ",[17,14055,14056],{},"AbortSignal.timeout(ms)"," gives you a timeout without a ",[17,14059,14060],{},"setTimeout\u002FclearTimeout"," dance. Cancellation is a platform feature, not a library concern.",[11,14063,14064,8473,14067,14070,14071,14073,14074,158,14077,14080,14081,14084],{},[35,14065,14066],{},"Streaming bodies.",[17,14068,14069],{},"Response.body"," is a ",[17,14072,12877],{},". For downloads, you can read chunks as they arrive: enough for a bytes-loaded counter, a live parser, or handing the stream into something else (",[17,14075,14076],{},"createImageBitmap",[17,14078,14079],{},"new Response(stream)",", the Cache API). ",[17,14082,14083],{},"Request.body"," accepts streams too, though upload progress is the one place this doesn't translate.",[11,14086,14087,8473,14090,158,14093,158,14096,158,14099,158,14101,158,14104,14107,14108,14111,14112,14115],{},[35,14088,14089],{},"First-class request options.",[17,14091,14092],{},"credentials",[17,14094,14095],{},"cache",[17,14097,14098],{},"redirect",[17,14100,10948],{},[17,14102,14103],{},"referrer",[17,14105,14106],{},"integrity",": all top-level ",[17,14109,14110],{},"RequestInit"," fields. No ",[17,14113,14114],{},"xhr.withCredentials = true"," side-effect, no forbidden-header workarounds. The API was designed with modern security and caching semantics in mind.",[11,14117,14118,14121,14122,14124,14125,14128],{},[35,14119,14120],{},"Cross-runtime consistency."," The same ",[17,14123,12870],{}," signature runs in browsers, Node 18+, Bun, Deno, Cloudflare Workers, and Service Workers. An SSR-capable client doesn't need a ",[17,14126,14127],{},"typeof window"," branch. Server-rendered code and client-rendered code can share one HTTP layer.",[11,14130,14131,8473,14134,14136],{},[35,14132,14133],{},"Extensibility without class hierarchies.",[17,14135,12870],{}," is a plain function. Your custom client is also a plain function with the same signature. Wrapping, currying, proxying, replacing: all standard techniques apply. That's what makes the next section tractable.",[25,14138,14140],{"id":14139},"building-a-custom-fetch-wrapper","Building a custom fetch wrapper",[11,14142,14143,14144,14146,14147,3722,14149,14152,14153,14155],{},"Most production codebases don't call ",[17,14145,12870],{}," directly. They have a ",[17,14148,13991],{},[17,14150,14151],{},"apiClient"," that layers on defaults, auth headers, timeouts, retry, error normalization, and sometimes interceptors. The trick is to build it without losing ",[17,14154,12870],{},"'s shape, so swapping in a different transport (like the XHR-backed one below) doesn't force a rewrite of every consumer.",[11,14157,14158],{},"I'll walk through four layers, each small, each earning its weight.",[1012,14160,14162],{"id":14161},"layer-1-base-url-default-headers-parsed-json","Layer 1: base URL, default headers, parsed JSON",[11,14164,14165],{},"Start with the shortest useful wrapper:",[39,14167,14169],{"className":1888,"code":14168,"language":1890,"meta":44,"style":44},"export async function request\u003CT>(path: string, init: RequestInit = {}): Promise\u003CT> {\n  const res = await fetch(path, init);\n  if (!res.ok) throw new ApiError(res.status, await res.text());\n  return res.json() as Promise\u003CT>;\n}\n",[17,14170,14171,14219,14234,14263,14283],{"__ignoreMap":44},[48,14172,14173,14175,14177,14179,14182,14184,14186,14188,14191,14193,14195,14197,14200,14202,14205,14207,14209,14211,14213,14215,14217],{"class":50,"line":51},[48,14174,3337],{"class":62},[48,14176,3340],{"class":62},[48,14178,3343],{"class":62},[48,14180,14181],{"class":261}," request",[48,14183,55],{"class":54},[48,14185,6587],{"class":261},[48,14187,683],{"class":54},[48,14189,14190],{"class":600},"path",[48,14192,336],{"class":62},[48,14194,606],{"class":332},[48,14196,158],{"class":54},[48,14198,14199],{"class":600},"init",[48,14201,336],{"class":62},[48,14203,14204],{"class":261}," RequestInit",[48,14206,342],{"class":62},[48,14208,3361],{"class":54},[48,14210,336],{"class":62},[48,14212,3366],{"class":261},[48,14214,55],{"class":54},[48,14216,6587],{"class":261},[48,14218,3373],{"class":54},[48,14220,14221,14223,14225,14227,14229,14231],{"class":50,"line":81},[48,14222,2604],{"class":62},[48,14224,3875],{"class":332},[48,14226,342],{"class":62},[48,14228,3551],{"class":62},[48,14230,3882],{"class":261},[48,14232,14233],{"class":54},"(path, init);\n",[48,14235,14236,14238,14240,14242,14244,14247,14249,14251,14254,14256,14258,14260],{"class":50,"line":87},[48,14237,2450],{"class":62},[48,14239,2180],{"class":54},[48,14241,2455],{"class":62},[48,14243,3901],{"class":54},[48,14245,14246],{"class":62},"throw",[48,14248,3832],{"class":62},[48,14250,13084],{"class":261},[48,14252,14253],{"class":54},"(res.status, ",[48,14255,3921],{"class":62},[48,14257,3924],{"class":54},[48,14259,13713],{"class":261},[48,14261,14262],{"class":54},"());\n",[48,14264,14265,14267,14269,14271,14273,14275,14277,14279,14281],{"class":50,"line":93},[48,14266,2963],{"class":62},[48,14268,3924],{"class":54},[48,14270,3927],{"class":261},[48,14272,3611],{"class":54},[48,14274,2658],{"class":62},[48,14276,3366],{"class":261},[48,14278,55],{"class":54},[48,14280,6587],{"class":261},[48,14282,6614],{"class":54},[48,14284,14285],{"class":50,"line":362},[48,14286,1717],{"class":54},[11,14288,14289],{},"Three lines buy you two behaviors: \"throw on non-2xx\" and \"parse JSON on success.\" Once you find yourself repeating those at every call site, the wrapper earns its place.",[11,14291,14292],{},"A slightly more useful shape accepts configuration:",[39,14294,14296],{"className":1888,"code":14295,"language":1890,"meta":44,"style":44},"export function createClient(options: {\n  baseURL: string;\n  defaultHeaders?: Record\u003Cstring, string>;\n}) {\n  const { baseURL, defaultHeaders = {} } = options;\n\n  return async function request\u003CT>(path: string, init: RequestInit = {}): Promise\u003CT> {\n    const url = new URL(path, baseURL).toString();\n    const headers = { ...defaultHeaders, ...(init.headers as Record\u003Cstring, string>) };\n    const res = await fetch(url, { ...init, headers });\n    if (!res.ok) throw new ApiError(res.status, await res.text());\n    return res.json() as Promise\u003CT>;\n  };\n}\n",[17,14297,14298,14315,14325,14344,14349,14373,14377,14421,14442,14478,14498,14524,14544,14548],{"__ignoreMap":44},[48,14299,14300,14302,14304,14307,14309,14311,14313],{"class":50,"line":51},[48,14301,3337],{"class":62},[48,14303,3343],{"class":62},[48,14305,14306],{"class":261}," createClient",[48,14308,624],{"class":54},[48,14310,5404],{"class":600},[48,14312,336],{"class":62},[48,14314,1623],{"class":54},[48,14316,14317,14319,14321,14323],{"class":50,"line":81},[48,14318,12946],{"class":600},[48,14320,336],{"class":62},[48,14322,606],{"class":332},[48,14324,318],{"class":54},[48,14326,14327,14330,14332,14334,14336,14338,14340,14342],{"class":50,"line":87},[48,14328,14329],{"class":600},"  defaultHeaders",[48,14331,3940],{"class":62},[48,14333,5444],{"class":261},[48,14335,55],{"class":54},[48,14337,1909],{"class":332},[48,14339,158],{"class":54},[48,14341,1909],{"class":332},[48,14343,6614],{"class":54},[48,14345,14346],{"class":50,"line":93},[48,14347,14348],{"class":54},"}) {\n",[48,14350,14351,14353,14355,14358,14360,14363,14365,14368,14370],{"class":50,"line":362},[48,14352,2604],{"class":62},[48,14354,700],{"class":54},[48,14356,14357],{"class":332},"baseURL",[48,14359,158],{"class":54},[48,14361,14362],{"class":332},"defaultHeaders",[48,14364,342],{"class":62},[48,14366,14367],{"class":54}," {} } ",[48,14369,66],{"class":62},[48,14371,14372],{"class":54}," options;\n",[48,14374,14375],{"class":50,"line":373},[48,14376,324],{"emptyLinePlaceholder":323},[48,14378,14379,14381,14383,14385,14387,14389,14391,14393,14395,14397,14399,14401,14403,14405,14407,14409,14411,14413,14415,14417,14419],{"class":50,"line":384},[48,14380,2963],{"class":62},[48,14382,3340],{"class":62},[48,14384,3343],{"class":62},[48,14386,14181],{"class":261},[48,14388,55],{"class":54},[48,14390,6587],{"class":261},[48,14392,683],{"class":54},[48,14394,14190],{"class":600},[48,14396,336],{"class":62},[48,14398,606],{"class":332},[48,14400,158],{"class":54},[48,14402,14199],{"class":600},[48,14404,336],{"class":62},[48,14406,14204],{"class":261},[48,14408,342],{"class":62},[48,14410,3361],{"class":54},[48,14412,336],{"class":62},[48,14414,3366],{"class":261},[48,14416,55],{"class":54},[48,14418,6587],{"class":261},[48,14420,3373],{"class":54},[48,14422,14423,14425,14428,14430,14432,14435,14438,14440],{"class":50,"line":661},[48,14424,2201],{"class":62},[48,14426,14427],{"class":332}," url",[48,14429,342],{"class":62},[48,14431,3832],{"class":62},[48,14433,14434],{"class":261}," URL",[48,14436,14437],{"class":54},"(path, baseURL).",[48,14439,3980],{"class":261},[48,14441,3473],{"class":54},[48,14443,14444,14446,14449,14451,14453,14455,14458,14460,14463,14465,14467,14469,14471,14473,14475],{"class":50,"line":690},[48,14445,2201],{"class":62},[48,14447,14448],{"class":332}," headers",[48,14450,342],{"class":62},[48,14452,700],{"class":54},[48,14454,9475],{"class":62},[48,14456,14457],{"class":54},"defaultHeaders, ",[48,14459,9475],{"class":62},[48,14461,14462],{"class":54},"(init.headers ",[48,14464,2658],{"class":62},[48,14466,5444],{"class":261},[48,14468,55],{"class":54},[48,14470,1909],{"class":332},[48,14472,158],{"class":54},[48,14474,1909],{"class":332},[48,14476,14477],{"class":54},">) };\n",[48,14479,14480,14482,14484,14486,14488,14490,14493,14495],{"class":50,"line":695},[48,14481,2201],{"class":62},[48,14483,3875],{"class":332},[48,14485,342],{"class":62},[48,14487,3551],{"class":62},[48,14489,3882],{"class":261},[48,14491,14492],{"class":54},"(url, { ",[48,14494,9475],{"class":62},[48,14496,14497],{"class":54},"init, headers });\n",[48,14499,14500,14502,14504,14506,14508,14510,14512,14514,14516,14518,14520,14522],{"class":50,"line":722},[48,14501,2135],{"class":62},[48,14503,2180],{"class":54},[48,14505,2455],{"class":62},[48,14507,3901],{"class":54},[48,14509,14246],{"class":62},[48,14511,3832],{"class":62},[48,14513,13084],{"class":261},[48,14515,14253],{"class":54},[48,14517,3921],{"class":62},[48,14519,3924],{"class":54},[48,14521,13713],{"class":261},[48,14523,14262],{"class":54},[48,14525,14526,14528,14530,14532,14534,14536,14538,14540,14542],{"class":50,"line":743},[48,14527,2946],{"class":62},[48,14529,3924],{"class":54},[48,14531,3927],{"class":261},[48,14533,3611],{"class":54},[48,14535,2658],{"class":62},[48,14537,3366],{"class":261},[48,14539,55],{"class":54},[48,14541,6587],{"class":261},[48,14543,6614],{"class":54},[48,14545,14546],{"class":50,"line":767},[48,14547,12680],{"class":54},[48,14549,14550],{"class":50,"line":778},[48,14551,1717],{"class":54},[11,14553,14554,14557,14558,14561,14562,14565],{},[17,14555,14556],{},"new URL(path, baseURL)"," is the piece worth stealing. It handles leading and trailing slashes correctly without the manual ",[17,14559,14560],{},"${baseURL}\u002F${path}.replace(\u002F\\\u002F+\u002Fg, '\u002F')"," dance that every ad-hoc client eventually grows. Pass a relative path and it resolves against the base; pass an absolute URL and ",[17,14563,14564],{},"URL"," uses it as-is.",[1012,14567,14569,14570],{"id":14568},"layer-2-timeouts-via-abortsignal","Layer 2: timeouts via ",[17,14571,14572],{},"AbortSignal",[11,14574,14575,14576,336],{},"The right way to do timeouts is ",[17,14577,14056],{},[39,14579,14581],{"className":1888,"code":14580,"language":1890,"meta":44,"style":44},"const signal = AbortSignal.timeout(30_000);\nconst res = await fetch(url, { signal });\n",[17,14582,14583,14605],{"__ignoreMap":44},[48,14584,14585,14587,14590,14592,14595,14598,14600,14603],{"class":50,"line":51},[48,14586,329],{"class":62},[48,14588,14589],{"class":332}," signal",[48,14591,342],{"class":62},[48,14593,14594],{"class":54}," AbortSignal.",[48,14596,14597],{"class":261},"timeout",[48,14599,624],{"class":54},[48,14601,14602],{"class":332},"30_000",[48,14604,629],{"class":54},[48,14606,14607,14609,14611,14613,14615,14617],{"class":50,"line":81},[48,14608,329],{"class":62},[48,14610,3875],{"class":332},[48,14612,342],{"class":62},[48,14614,3551],{"class":62},[48,14616,3882],{"class":261},[48,14618,14619],{"class":54},"(url, { signal });\n",[11,14621,14622],{},"If a caller also passes their own signal, merge them so either one can cancel the request:",[39,14624,14626],{"className":1888,"code":14625,"language":1890,"meta":44,"style":44},"function mergeSignals(a?: AbortSignal, b?: AbortSignal): AbortSignal | undefined {\n  if (!a) return b;\n  if (!b) return a;\n  if ('any' in AbortSignal) return AbortSignal.any([a, b]);\n  \u002F\u002F fallback for older runtimes\n  const controller = new AbortController();\n  const onAbort = () => controller.abort();\n  a.addEventListener('abort', onAbort, { once: true });\n  b.addEventListener('abort', onAbort, { once: true });\n  return controller.signal;\n}\n",[17,14627,14628,14664,14680,14696,14720,14725,14739,14758,14777,14794,14801],{"__ignoreMap":44},[48,14629,14630,14632,14635,14637,14639,14641,14644,14646,14648,14650,14652,14654,14656,14658,14660,14662],{"class":50,"line":51},[48,14631,2419],{"class":62},[48,14633,14634],{"class":261}," mergeSignals",[48,14636,624],{"class":54},[48,14638,9485],{"class":600},[48,14640,3940],{"class":62},[48,14642,14643],{"class":261}," AbortSignal",[48,14645,158],{"class":54},[48,14647,9490],{"class":600},[48,14649,3940],{"class":62},[48,14651,14643],{"class":261},[48,14653,2434],{"class":54},[48,14655,336],{"class":62},[48,14657,14643],{"class":261},[48,14659,678],{"class":62},[48,14661,5429],{"class":332},[48,14663,1623],{"class":54},[48,14665,14666,14668,14670,14672,14675,14677],{"class":50,"line":81},[48,14667,2450],{"class":62},[48,14669,2180],{"class":54},[48,14671,2455],{"class":62},[48,14673,14674],{"class":54},"a) ",[48,14676,2194],{"class":62},[48,14678,14679],{"class":54}," b;\n",[48,14681,14682,14684,14686,14688,14691,14693],{"class":50,"line":87},[48,14683,2450],{"class":62},[48,14685,2180],{"class":54},[48,14687,2455],{"class":62},[48,14689,14690],{"class":54},"b) ",[48,14692,2194],{"class":62},[48,14694,14695],{"class":54}," a;\n",[48,14697,14698,14700,14702,14705,14707,14710,14712,14714,14717],{"class":50,"line":93},[48,14699,2450],{"class":62},[48,14701,2180],{"class":54},[48,14703,14704],{"class":69},"'any'",[48,14706,8234],{"class":62},[48,14708,14709],{"class":54}," AbortSignal) ",[48,14711,2194],{"class":62},[48,14713,14594],{"class":54},[48,14715,14716],{"class":261},"any",[48,14718,14719],{"class":54},"([a, b]);\n",[48,14721,14722],{"class":50,"line":362},[48,14723,14724],{"class":2473},"  \u002F\u002F fallback for older runtimes\n",[48,14726,14727,14729,14731,14733,14735,14737],{"class":50,"line":373},[48,14728,2604],{"class":62},[48,14730,3827],{"class":332},[48,14732,342],{"class":62},[48,14734,3832],{"class":62},[48,14736,3835],{"class":261},[48,14738,3473],{"class":54},[48,14740,14741,14743,14746,14748,14750,14752,14754,14756],{"class":50,"line":384},[48,14742,2604],{"class":62},[48,14744,14745],{"class":261}," onAbort",[48,14747,342],{"class":62},[48,14749,4427],{"class":54},[48,14751,647],{"class":62},[48,14753,3856],{"class":54},[48,14755,3859],{"class":261},[48,14757,3473],{"class":54},[48,14759,14760,14763,14765,14767,14770,14773,14775],{"class":50,"line":661},[48,14761,14762],{"class":54},"  a.",[48,14764,11699],{"class":261},[48,14766,624],{"class":54},[48,14768,14769],{"class":69},"'abort'",[48,14771,14772],{"class":54},", onAbort, { once: ",[48,14774,773],{"class":332},[48,14776,1922],{"class":54},[48,14778,14779,14782,14784,14786,14788,14790,14792],{"class":50,"line":690},[48,14780,14781],{"class":54},"  b.",[48,14783,11699],{"class":261},[48,14785,624],{"class":54},[48,14787,14769],{"class":69},[48,14789,14772],{"class":54},[48,14791,773],{"class":332},[48,14793,1922],{"class":54},[48,14795,14796,14798],{"class":50,"line":695},[48,14797,2963],{"class":62},[48,14799,14800],{"class":54}," controller.signal;\n",[48,14802,14803],{"class":50,"line":722},[48,14804,1717],{"class":54},[11,14806,14807,14809,14810,14812,14813,14816,14817,14820,14821,14824],{},[17,14808,14052],{}," does the merge natively in recent browsers; the fallback manually forwards both signals into a new controller. Either way, a single signal flows into ",[17,14811,12870],{},", and a timeout ",[2087,14814,14815],{},"or"," a caller abort resolves to the same rejection path, which matters because downstream ",[17,14818,14819],{},"try\u002Fcatch"," can check ",[17,14822,14823],{},"err.name === 'AbortError'"," regardless of which source fired.",[1012,14826,14828],{"id":14827},"layer-3-interceptors","Layer 3: interceptors",[11,14830,14831],{},"Three chains (request, response, error) turn a static client into an extensible one:",[39,14833,14835],{"className":1888,"code":14834,"language":1890,"meta":44,"style":44},"type RequestInterceptor = (ctx: { url: string; init: RequestInit }) => void | Promise\u003Cvoid>;\ntype ResponseInterceptor = (res: Response) => Response | Promise\u003CResponse>;\n\nexport function createClient(options: { baseURL: string; defaultHeaders?: Record\u003Cstring, string> }) {\n  const requestInterceptors: RequestInterceptor[] = [];\n  const responseInterceptors: ResponseInterceptor[] = [];\n\n  async function request\u003CT>(path: string, init: RequestInit = {}): Promise\u003CT> {\n    const ctx = {\n      url: new URL(path, options.baseURL).toString(),\n      init: { ...init, headers: { ...(options.defaultHeaders ?? {}), ...(init.headers as Record\u003Cstring, string>) } },\n    };\n    for (const fn of requestInterceptors) await fn(ctx);\n    let res = await fetch(ctx.url, ctx.init);\n    for (const fn of responseInterceptors) res = await fn(res);\n    if (!res.ok) throw new ApiError(res.status, await res.text());\n    return res.json() as Promise\u003CT>;\n  }\n\n  request.useRequest = (fn: RequestInterceptor) => {\n    requestInterceptors.push(fn);\n    return () => void requestInterceptors.splice(requestInterceptors.indexOf(fn), 1);\n  };\n  request.useResponse = (fn: ResponseInterceptor) => {\n    responseInterceptors.push(fn);\n    return () => void responseInterceptors.splice(responseInterceptors.indexOf(fn), 1);\n  };\n\n  return request;\n}\n",[17,14836,14837,14885,14920,14924,14965,14983,15000,15004,15046,15057,15074,15113,15117,15141,15158,15182,15208,15228,15232,15236,15260,15270,15299,15303,15325,15334,15360,15364,15368,15375],{"__ignoreMap":44},[48,14838,14839,14841,14844,14846,14848,14850,14852,14854,14857,14859,14861,14863,14865,14867,14869,14871,14873,14875,14877,14879,14881,14883],{"class":50,"line":51},[48,14840,306],{"class":62},[48,14842,14843],{"class":261}," RequestInterceptor",[48,14845,342],{"class":62},[48,14847,2180],{"class":54},[48,14849,12770],{"class":600},[48,14851,336],{"class":62},[48,14853,700],{"class":54},[48,14855,14856],{"class":600},"url",[48,14858,336],{"class":62},[48,14860,606],{"class":332},[48,14862,3945],{"class":54},[48,14864,14199],{"class":600},[48,14866,336],{"class":62},[48,14868,14204],{"class":261},[48,14870,4480],{"class":54},[48,14872,647],{"class":62},[48,14874,4807],{"class":332},[48,14876,678],{"class":62},[48,14878,3366],{"class":261},[48,14880,55],{"class":54},[48,14882,12137],{"class":332},[48,14884,6614],{"class":54},[48,14886,14887,14889,14892,14894,14896,14899,14901,14904,14906,14908,14910,14912,14914,14916,14918],{"class":50,"line":81},[48,14888,306],{"class":62},[48,14890,14891],{"class":261}," ResponseInterceptor",[48,14893,342],{"class":62},[48,14895,2180],{"class":54},[48,14897,14898],{"class":600},"res",[48,14900,336],{"class":62},[48,14902,14903],{"class":261}," Response",[48,14905,806],{"class":54},[48,14907,647],{"class":62},[48,14909,14903],{"class":261},[48,14911,678],{"class":62},[48,14913,3366],{"class":261},[48,14915,55],{"class":54},[48,14917,13933],{"class":261},[48,14919,6614],{"class":54},[48,14921,14922],{"class":50,"line":87},[48,14923,324],{"emptyLinePlaceholder":323},[48,14925,14926,14928,14930,14932,14934,14936,14938,14940,14942,14944,14946,14948,14950,14952,14954,14956,14958,14960,14962],{"class":50,"line":93},[48,14927,3337],{"class":62},[48,14929,3343],{"class":62},[48,14931,14306],{"class":261},[48,14933,624],{"class":54},[48,14935,5404],{"class":600},[48,14937,336],{"class":62},[48,14939,700],{"class":54},[48,14941,14357],{"class":600},[48,14943,336],{"class":62},[48,14945,606],{"class":332},[48,14947,3945],{"class":54},[48,14949,14362],{"class":600},[48,14951,3940],{"class":62},[48,14953,5444],{"class":261},[48,14955,55],{"class":54},[48,14957,1909],{"class":332},[48,14959,158],{"class":54},[48,14961,1909],{"class":332},[48,14963,14964],{"class":54},"> }) {\n",[48,14966,14967,14969,14972,14974,14976,14979,14981],{"class":50,"line":362},[48,14968,2604],{"class":62},[48,14970,14971],{"class":332}," requestInterceptors",[48,14973,336],{"class":62},[48,14975,14843],{"class":261},[48,14977,14978],{"class":54},"[] ",[48,14980,66],{"class":62},[48,14982,11480],{"class":54},[48,14984,14985,14987,14990,14992,14994,14996,14998],{"class":50,"line":373},[48,14986,2604],{"class":62},[48,14988,14989],{"class":332}," responseInterceptors",[48,14991,336],{"class":62},[48,14993,14891],{"class":261},[48,14995,14978],{"class":54},[48,14997,66],{"class":62},[48,14999,11480],{"class":54},[48,15001,15002],{"class":50,"line":384},[48,15003,324],{"emptyLinePlaceholder":323},[48,15005,15006,15008,15010,15012,15014,15016,15018,15020,15022,15024,15026,15028,15030,15032,15034,15036,15038,15040,15042,15044],{"class":50,"line":661},[48,15007,11451],{"class":62},[48,15009,3343],{"class":62},[48,15011,14181],{"class":261},[48,15013,55],{"class":54},[48,15015,6587],{"class":261},[48,15017,683],{"class":54},[48,15019,14190],{"class":600},[48,15021,336],{"class":62},[48,15023,606],{"class":332},[48,15025,158],{"class":54},[48,15027,14199],{"class":600},[48,15029,336],{"class":62},[48,15031,14204],{"class":261},[48,15033,342],{"class":62},[48,15035,3361],{"class":54},[48,15037,336],{"class":62},[48,15039,3366],{"class":261},[48,15041,55],{"class":54},[48,15043,6587],{"class":261},[48,15045,3373],{"class":54},[48,15047,15048,15050,15053,15055],{"class":50,"line":690},[48,15049,2201],{"class":62},[48,15051,15052],{"class":332}," ctx",[48,15054,342],{"class":62},[48,15056,1623],{"class":54},[48,15058,15059,15062,15064,15066,15069,15071],{"class":50,"line":695},[48,15060,15061],{"class":54},"      url: ",[48,15063,11422],{"class":62},[48,15065,14434],{"class":261},[48,15067,15068],{"class":54},"(path, options.baseURL).",[48,15070,3980],{"class":261},[48,15072,15073],{"class":54},"(),\n",[48,15075,15076,15079,15081,15084,15086,15089,15091,15094,15096,15098,15100,15102,15104,15106,15108,15110],{"class":50,"line":722},[48,15077,15078],{"class":54},"      init: { ",[48,15080,9475],{"class":62},[48,15082,15083],{"class":54},"init, headers: { ",[48,15085,9475],{"class":62},[48,15087,15088],{"class":54},"(options.defaultHeaders ",[48,15090,2556],{"class":62},[48,15092,15093],{"class":54}," {}), ",[48,15095,9475],{"class":62},[48,15097,14462],{"class":54},[48,15099,2658],{"class":62},[48,15101,5444],{"class":261},[48,15103,55],{"class":54},[48,15105,1909],{"class":332},[48,15107,158],{"class":54},[48,15109,1909],{"class":332},[48,15111,15112],{"class":54},">) } },\n",[48,15114,15115],{"class":50,"line":743},[48,15116,6546],{"class":54},[48,15118,15119,15122,15124,15126,15128,15131,15134,15136,15138],{"class":50,"line":767},[48,15120,15121],{"class":62},"    for",[48,15123,2180],{"class":54},[48,15125,329],{"class":62},[48,15127,10530],{"class":332},[48,15129,15130],{"class":62}," of",[48,15132,15133],{"class":54}," requestInterceptors) ",[48,15135,3921],{"class":62},[48,15137,10530],{"class":261},[48,15139,15140],{"class":54},"(ctx);\n",[48,15142,15143,15146,15149,15151,15153,15155],{"class":50,"line":778},[48,15144,15145],{"class":62},"    let",[48,15147,15148],{"class":54}," res ",[48,15150,66],{"class":62},[48,15152,3551],{"class":62},[48,15154,3882],{"class":261},[48,15156,15157],{"class":54},"(ctx.url, ctx.init);\n",[48,15159,15160,15162,15164,15166,15168,15170,15173,15175,15177,15179],{"class":50,"line":783},[48,15161,15121],{"class":62},[48,15163,2180],{"class":54},[48,15165,329],{"class":62},[48,15167,10530],{"class":332},[48,15169,15130],{"class":62},[48,15171,15172],{"class":54}," responseInterceptors) res ",[48,15174,66],{"class":62},[48,15176,3551],{"class":62},[48,15178,10530],{"class":261},[48,15180,15181],{"class":54},"(res);\n",[48,15183,15184,15186,15188,15190,15192,15194,15196,15198,15200,15202,15204,15206],{"class":50,"line":788},[48,15185,2135],{"class":62},[48,15187,2180],{"class":54},[48,15189,2455],{"class":62},[48,15191,3901],{"class":54},[48,15193,14246],{"class":62},[48,15195,3832],{"class":62},[48,15197,13084],{"class":261},[48,15199,14253],{"class":54},[48,15201,3921],{"class":62},[48,15203,3924],{"class":54},[48,15205,13713],{"class":261},[48,15207,14262],{"class":54},[48,15209,15210,15212,15214,15216,15218,15220,15222,15224,15226],{"class":50,"line":819},[48,15211,2946],{"class":62},[48,15213,3924],{"class":54},[48,15215,3927],{"class":261},[48,15217,3611],{"class":54},[48,15219,2658],{"class":62},[48,15221,3366],{"class":261},[48,15223,55],{"class":54},[48,15225,6587],{"class":261},[48,15227,6614],{"class":54},[48,15229,15230],{"class":50,"line":828},[48,15231,2590],{"class":54},[48,15233,15234],{"class":50,"line":833},[48,15235,324],{"emptyLinePlaceholder":323},[48,15237,15238,15241,15243,15245,15247,15250,15252,15254,15256,15258],{"class":50,"line":843},[48,15239,15240],{"class":54},"  request.",[48,15242,12765],{"class":261},[48,15244,342],{"class":62},[48,15246,2180],{"class":54},[48,15248,15249],{"class":600},"fn",[48,15251,336],{"class":62},[48,15253,14843],{"class":261},[48,15255,806],{"class":54},[48,15257,647],{"class":62},[48,15259,1623],{"class":54},[48,15261,15262,15265,15267],{"class":50,"line":860},[48,15263,15264],{"class":54},"    requestInterceptors.",[48,15266,12540],{"class":261},[48,15268,15269],{"class":54},"(fn);\n",[48,15271,15272,15274,15276,15278,15280,15283,15286,15289,15292,15295,15297],{"class":50,"line":887},[48,15273,2946],{"class":62},[48,15275,4427],{"class":54},[48,15277,647],{"class":62},[48,15279,4807],{"class":62},[48,15281,15282],{"class":54}," requestInterceptors.",[48,15284,15285],{"class":261},"splice",[48,15287,15288],{"class":54},"(requestInterceptors.",[48,15290,15291],{"class":261},"indexOf",[48,15293,15294],{"class":54},"(fn), ",[48,15296,2082],{"class":332},[48,15298,629],{"class":54},[48,15300,15301],{"class":50,"line":905},[48,15302,12680],{"class":54},[48,15304,15305,15307,15309,15311,15313,15315,15317,15319,15321,15323],{"class":50,"line":926},[48,15306,15240],{"class":54},[48,15308,12808],{"class":261},[48,15310,342],{"class":62},[48,15312,2180],{"class":54},[48,15314,15249],{"class":600},[48,15316,336],{"class":62},[48,15318,14891],{"class":261},[48,15320,806],{"class":54},[48,15322,647],{"class":62},[48,15324,1623],{"class":54},[48,15326,15327,15330,15332],{"class":50,"line":936},[48,15328,15329],{"class":54},"    responseInterceptors.",[48,15331,12540],{"class":261},[48,15333,15269],{"class":54},[48,15335,15336,15338,15340,15342,15344,15347,15349,15352,15354,15356,15358],{"class":50,"line":2726},[48,15337,2946],{"class":62},[48,15339,4427],{"class":54},[48,15341,647],{"class":62},[48,15343,4807],{"class":62},[48,15345,15346],{"class":54}," responseInterceptors.",[48,15348,15285],{"class":261},[48,15350,15351],{"class":54},"(responseInterceptors.",[48,15353,15291],{"class":261},[48,15355,15294],{"class":54},[48,15357,2082],{"class":332},[48,15359,629],{"class":54},[48,15361,15362],{"class":50,"line":2732},[48,15363,12680],{"class":54},[48,15365,15366],{"class":50,"line":2737},[48,15367,324],{"emptyLinePlaceholder":323},[48,15369,15370,15372],{"class":50,"line":2747},[48,15371,2963],{"class":62},[48,15373,15374],{"class":54}," request;\n",[48,15376,15377],{"class":50,"line":2753},[48,15378,1717],{"class":54},[11,15380,12856,15381,15383],{},[17,15382,12859],{}," method returns an unsubscribe function, which matters when you register an interceptor from a component and want to clean up on unmount.",[11,15385,15386],{},"Auth becomes a one-liner:",[39,15388,15390],{"className":1888,"code":15389,"language":1890,"meta":44,"style":44},"api.useRequest((ctx) => {\n  ctx.init.headers = { ...(ctx.init.headers as Record\u003Cstring, string>), Authorization: `Bearer ${token.value}` };\n});\n",[17,15391,15392,15409,15451],{"__ignoreMap":44},[48,15393,15394,15397,15399,15401,15403,15405,15407],{"class":50,"line":51},[48,15395,15396],{"class":54},"api.",[48,15398,12765],{"class":261},[48,15400,800],{"class":54},[48,15402,12770],{"class":600},[48,15404,806],{"class":54},[48,15406,647],{"class":62},[48,15408,1623],{"class":54},[48,15410,15411,15414,15416,15418,15420,15423,15425,15427,15429,15431,15433,15435,15438,15441,15443,15445,15447,15449],{"class":50,"line":81},[48,15412,15413],{"class":54},"  ctx.init.headers ",[48,15415,66],{"class":62},[48,15417,700],{"class":54},[48,15419,9475],{"class":62},[48,15421,15422],{"class":54},"(ctx.init.headers ",[48,15424,2658],{"class":62},[48,15426,5444],{"class":261},[48,15428,55],{"class":54},[48,15430,1909],{"class":332},[48,15432,158],{"class":54},[48,15434,1909],{"class":332},[48,15436,15437],{"class":54},">), Authorization: ",[48,15439,15440],{"class":69},"`Bearer ${",[48,15442,12789],{"class":54},[48,15444,526],{"class":69},[48,15446,4878],{"class":54},[48,15448,738],{"class":69},[48,15450,3954],{"class":54},[48,15452,15453],{"class":50,"line":87},[48,15454,387],{"class":54},[11,15456,15457],{},"So does \"refresh on 401\":",[39,15459,15461],{"className":1888,"code":15460,"language":1890,"meta":44,"style":44},"api.useResponse(async (res) => {\n  if (res.status !== 401) return res;\n  await refreshAuth();\n  return fetch(res.url, { \u002F* re-run options *\u002F });\n});\n",[17,15462,15463,15483,15502,15512,15526],{"__ignoreMap":44},[48,15464,15465,15467,15469,15471,15473,15475,15477,15479,15481],{"class":50,"line":51},[48,15466,15396],{"class":54},[48,15468,12808],{"class":261},[48,15470,624],{"class":54},[48,15472,3746],{"class":62},[48,15474,2180],{"class":54},[48,15476,14898],{"class":600},[48,15478,806],{"class":54},[48,15480,647],{"class":62},[48,15482,1623],{"class":54},[48,15484,15485,15487,15490,15492,15495,15497,15499],{"class":50,"line":81},[48,15486,2450],{"class":62},[48,15488,15489],{"class":54}," (res.status ",[48,15491,3809],{"class":62},[48,15493,15494],{"class":332}," 401",[48,15496,806],{"class":54},[48,15498,2194],{"class":62},[48,15500,15501],{"class":54}," res;\n",[48,15503,15504,15507,15510],{"class":50,"line":87},[48,15505,15506],{"class":62},"  await",[48,15508,15509],{"class":261}," refreshAuth",[48,15511,3473],{"class":54},[48,15513,15514,15516,15518,15521,15524],{"class":50,"line":93},[48,15515,2963],{"class":62},[48,15517,3882],{"class":261},[48,15519,15520],{"class":54},"(res.url, { ",[48,15522,15523],{"class":2473},"\u002F* re-run options *\u002F",[48,15525,1922],{"class":54},[48,15527,15528],{"class":50,"line":362},[48,15529,387],{"class":54},[1012,15531,15533],{"id":15532},"layer-4-retry-with-exponential-backoff","Layer 4: retry with exponential backoff",[11,15535,15536],{},"For idempotent requests (GET, HEAD, most PUTs, DELETEs), a small retry helper absorbs transient network failures:",[39,15538,15540],{"className":1888,"code":15539,"language":1890,"meta":44,"style":44},"async function withRetry\u003CT>(fn: () => Promise\u003CT>, attempts = 2, baseDelayMs = 200): Promise\u003CT> {\n  let lastError: unknown;\n  for (let i = 0; i \u003C= attempts; i++) {\n    try {\n      return await fn();\n    } catch (e) {\n      lastError = e;\n      if (i \u003C attempts) await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** i));\n    }\n  }\n  throw lastError;\n}\n",[17,15541,15542,15601,15615,15644,15650,15660,15669,15678,15720,15724,15728,15736],{"__ignoreMap":44},[48,15543,15544,15546,15548,15551,15553,15555,15557,15559,15561,15563,15565,15567,15569,15571,15573,15575,15577,15580,15582,15584,15586,15589,15591,15593,15595,15597,15599],{"class":50,"line":51},[48,15545,3746],{"class":62},[48,15547,3343],{"class":62},[48,15549,15550],{"class":261}," withRetry",[48,15552,55],{"class":54},[48,15554,6587],{"class":261},[48,15556,683],{"class":54},[48,15558,15249],{"class":261},[48,15560,336],{"class":62},[48,15562,4427],{"class":54},[48,15564,647],{"class":62},[48,15566,3366],{"class":261},[48,15568,55],{"class":54},[48,15570,6587],{"class":261},[48,15572,7737],{"class":54},[48,15574,12660],{"class":600},[48,15576,342],{"class":62},[48,15578,15579],{"class":332}," 2",[48,15581,158],{"class":54},[48,15583,12669],{"class":600},[48,15585,342],{"class":62},[48,15587,15588],{"class":332}," 200",[48,15590,2434],{"class":54},[48,15592,336],{"class":62},[48,15594,3366],{"class":261},[48,15596,55],{"class":54},[48,15598,6587],{"class":261},[48,15600,3373],{"class":54},[48,15602,15603,15606,15609,15611,15613],{"class":50,"line":81},[48,15604,15605],{"class":62},"  let",[48,15607,15608],{"class":54}," lastError",[48,15610,336],{"class":62},[48,15612,4432],{"class":332},[48,15614,318],{"class":54},[48,15616,15617,15619,15621,15623,15626,15628,15630,15633,15636,15639,15642],{"class":50,"line":87},[48,15618,2777],{"class":62},[48,15620,2180],{"class":54},[48,15622,2782],{"class":62},[48,15624,15625],{"class":54}," i ",[48,15627,66],{"class":62},[48,15629,8717],{"class":332},[48,15631,15632],{"class":54},"; i ",[48,15634,15635],{"class":62},"\u003C=",[48,15637,15638],{"class":54}," attempts; i",[48,15640,15641],{"class":62},"++",[48,15643,2146],{"class":54},[48,15645,15646,15648],{"class":50,"line":93},[48,15647,2640],{"class":62},[48,15649,1623],{"class":54},[48,15651,15652,15654,15656,15658],{"class":50,"line":362},[48,15653,2166],{"class":62},[48,15655,3551],{"class":62},[48,15657,10530],{"class":261},[48,15659,3473],{"class":54},[48,15661,15662,15664,15666],{"class":50,"line":373},[48,15663,2740],{"class":54},[48,15665,2578],{"class":62},[48,15667,15668],{"class":54}," (e) {\n",[48,15670,15671,15674,15676],{"class":50,"line":384},[48,15672,15673],{"class":54},"      lastError ",[48,15675,66],{"class":62},[48,15677,13174],{"class":54},[48,15679,15680,15682,15685,15687,15690,15692,15694,15696,15698,15701,15703,15705,15707,15710,15712,15714,15717],{"class":50,"line":661},[48,15681,2151],{"class":62},[48,15683,15684],{"class":54}," (i ",[48,15686,55],{"class":62},[48,15688,15689],{"class":54}," attempts) ",[48,15691,3921],{"class":62},[48,15693,3832],{"class":62},[48,15695,3366],{"class":332},[48,15697,800],{"class":54},[48,15699,15700],{"class":600},"r",[48,15702,806],{"class":54},[48,15704,647],{"class":62},[48,15706,3849],{"class":261},[48,15708,15709],{"class":54},"(r, baseDelayMs ",[48,15711,13298],{"class":62},[48,15713,15579],{"class":332},[48,15715,15716],{"class":62}," **",[48,15718,15719],{"class":54}," i));\n",[48,15721,15722],{"class":50,"line":690},[48,15723,2173],{"class":54},[48,15725,15726],{"class":50,"line":695},[48,15727,2590],{"class":54},[48,15729,15730,15733],{"class":50,"line":722},[48,15731,15732],{"class":62},"  throw",[48,15734,15735],{"class":54}," lastError;\n",[48,15737,15738],{"class":50,"line":743},[48,15739,1717],{"class":54},[11,15741,15742,15743,15746],{},"Three attempts with 200ms \u002F 400ms \u002F 800ms backoff covers most spurious 502s and DNS blips. Retry non-idempotent requests at your peril: re-sending a POST that already committed is worse than failing loudly. A small allow-list (",[17,15744,15745],{},"['GET', 'HEAD', 'PUT', 'DELETE']",") inside the wrapper is usually how this gets enforced.",[1012,15748,15750],{"id":15749},"what-you-end-up-with","What you end up with",[11,15752,15753],{},"After those four layers, the consumer surface looks like:",[39,15755,15757],{"className":1888,"code":15756,"language":1890,"meta":44,"style":44},"const api = createClient({\n  baseURL: 'https:\u002F\u002Fapi.example.com',\n  defaultHeaders: { Accept: 'application\u002Fjson' },\n});\n\napi.useRequest((ctx) => {\n  ctx.init.headers = { ...(ctx.init.headers as Record\u003Cstring, string>), Authorization: `Bearer ${token.value}` };\n});\n\nconst users = await api\u003CUser[]>('\u002Fusers');\n",[17,15758,15759,15772,15781,15791,15795,15799,15815,15853,15857,15861],{"__ignoreMap":44},[48,15760,15761,15763,15766,15768,15770],{"class":50,"line":51},[48,15762,329],{"class":62},[48,15764,15765],{"class":332}," api",[48,15767,342],{"class":62},[48,15769,14306],{"class":261},[48,15771,719],{"class":54},[48,15773,15774,15777,15779],{"class":50,"line":81},[48,15775,15776],{"class":54},"  baseURL: ",[48,15778,12951],{"class":69},[48,15780,359],{"class":54},[48,15782,15783,15786,15789],{"class":50,"line":87},[48,15784,15785],{"class":54},"  defaultHeaders: { Accept: ",[48,15787,15788],{"class":69},"'application\u002Fjson'",[48,15790,7411],{"class":54},[48,15792,15793],{"class":50,"line":93},[48,15794,387],{"class":54},[48,15796,15797],{"class":50,"line":362},[48,15798,324],{"emptyLinePlaceholder":323},[48,15800,15801,15803,15805,15807,15809,15811,15813],{"class":50,"line":373},[48,15802,15396],{"class":54},[48,15804,12765],{"class":261},[48,15806,800],{"class":54},[48,15808,12770],{"class":600},[48,15810,806],{"class":54},[48,15812,647],{"class":62},[48,15814,1623],{"class":54},[48,15816,15817,15819,15821,15823,15825,15827,15829,15831,15833,15835,15837,15839,15841,15843,15845,15847,15849,15851],{"class":50,"line":384},[48,15818,15413],{"class":54},[48,15820,66],{"class":62},[48,15822,700],{"class":54},[48,15824,9475],{"class":62},[48,15826,15422],{"class":54},[48,15828,2658],{"class":62},[48,15830,5444],{"class":261},[48,15832,55],{"class":54},[48,15834,1909],{"class":332},[48,15836,158],{"class":54},[48,15838,1909],{"class":332},[48,15840,15437],{"class":54},[48,15842,15440],{"class":69},[48,15844,12789],{"class":54},[48,15846,526],{"class":69},[48,15848,4878],{"class":54},[48,15850,738],{"class":69},[48,15852,3954],{"class":54},[48,15854,15855],{"class":50,"line":661},[48,15856,387],{"class":54},[48,15858,15859],{"class":50,"line":690},[48,15860,324],{"emptyLinePlaceholder":323},[48,15862,15863,15865,15867,15869,15871,15873,15875,15877,15879,15881],{"class":50,"line":695},[48,15864,329],{"class":62},[48,15866,12723],{"class":332},[48,15868,342],{"class":62},[48,15870,3551],{"class":62},[48,15872,15765],{"class":261},[48,15874,55],{"class":54},[48,15876,12735],{"class":261},[48,15878,12738],{"class":54},[48,15880,12741],{"class":69},[48,15882,629],{"class":54},[11,15884,15885,15886,526],{},"Flat call-shape, auth handled once, errors thrown as a known type, timeouts and retry applied where appropriate. Every layer is a plain function wrapping the one below. At the bottom is still ",[17,15887,12870],{},[11,15889,15890],{},"Which is exactly where the upload-progress gap lives.",[25,15892,15894,15895,15897],{"id":15893},"why-fetch-cant-show-upload-progress","Why ",[17,15896,12870],{}," can't show upload progress",[11,15899,15900,15901,15903],{},"Download progress with ",[17,15902,12870],{}," works, but awkwardly. You read the response body as a stream:",[39,15905,15907],{"className":1888,"code":15906,"language":1890,"meta":44,"style":44},"const res = await fetch('\u002Fbig.zip');\nconst total = Number(res.headers.get('content-length')) || null;\nlet loaded = 0;\nconst reader = res.body!.getReader();\nwhile (true) {\n  const { value, done } = await reader.read();\n  if (done) break;\n  loaded += value.byteLength;\n  onProgress(loaded, total);\n}\n",[17,15908,15909,15928,15958,15971,15992,16003,16030,16042,16053,16061],{"__ignoreMap":44},[48,15910,15911,15913,15915,15917,15919,15921,15923,15926],{"class":50,"line":51},[48,15912,329],{"class":62},[48,15914,3875],{"class":332},[48,15916,342],{"class":62},[48,15918,3551],{"class":62},[48,15920,3882],{"class":261},[48,15922,624],{"class":54},[48,15924,15925],{"class":69},"'\u002Fbig.zip'",[48,15927,629],{"class":54},[48,15929,15930,15932,15935,15937,15939,15942,15944,15946,15949,15952,15954,15956],{"class":50,"line":81},[48,15931,329],{"class":62},[48,15933,15934],{"class":332}," total",[48,15936,342],{"class":62},[48,15938,6116],{"class":261},[48,15940,15941],{"class":54},"(res.headers.",[48,15943,13480],{"class":261},[48,15945,624],{"class":54},[48,15947,15948],{"class":69},"'content-length'",[48,15950,15951],{"class":54},")) ",[48,15953,2694],{"class":62},[48,15955,656],{"class":332},[48,15957,318],{"class":54},[48,15959,15960,15962,15965,15967,15969],{"class":50,"line":87},[48,15961,2782],{"class":62},[48,15963,15964],{"class":54}," loaded ",[48,15966,66],{"class":62},[48,15968,8717],{"class":332},[48,15970,318],{"class":54},[48,15972,15973,15975,15978,15980,15983,15985,15987,15990],{"class":50,"line":93},[48,15974,329],{"class":62},[48,15976,15977],{"class":332}," reader",[48,15979,342],{"class":62},[48,15981,15982],{"class":54}," res.body",[48,15984,2455],{"class":62},[48,15986,526],{"class":54},[48,15988,15989],{"class":261},"getReader",[48,15991,3473],{"class":54},[48,15993,15994,15997,15999,16001],{"class":50,"line":362},[48,15995,15996],{"class":62},"while",[48,15998,2180],{"class":54},[48,16000,773],{"class":332},[48,16002,2146],{"class":54},[48,16004,16005,16007,16009,16011,16013,16016,16018,16020,16022,16025,16028],{"class":50,"line":373},[48,16006,2604],{"class":62},[48,16008,700],{"class":54},[48,16010,4878],{"class":332},[48,16012,158],{"class":54},[48,16014,16015],{"class":332},"done",[48,16017,711],{"class":54},[48,16019,66],{"class":62},[48,16021,3551],{"class":62},[48,16023,16024],{"class":54}," reader.",[48,16026,16027],{"class":261},"read",[48,16029,3473],{"class":54},[48,16031,16032,16034,16037,16040],{"class":50,"line":384},[48,16033,2450],{"class":62},[48,16035,16036],{"class":54}," (done) ",[48,16038,16039],{"class":62},"break",[48,16041,318],{"class":54},[48,16043,16044,16047,16050],{"class":50,"line":661},[48,16045,16046],{"class":54},"  loaded ",[48,16048,16049],{"class":62},"+=",[48,16051,16052],{"class":54}," value.byteLength;\n",[48,16054,16055,16058],{"class":50,"line":690},[48,16056,16057],{"class":261},"  onProgress",[48,16059,16060],{"class":54},"(loaded, total);\n",[48,16062,16063],{"class":50,"line":695},[48,16064,1717],{"class":54},[11,16066,16067,16068,16070],{},"That's fine. You do have to keep the chunks around yourself (or pipe them into a new ",[17,16069,13933],{}," if you want to hand them off), but the primitives are there.",[11,16072,16073,16074,16076,16077,16079,16080,16083,16084,16086,16087,16090],{},"For uploads, the missing piece is that the ",[17,16075,13930],{}," body is consumed by the browser's network stack, not by your code. You can pass a ",[17,16078,12877],{},", but there's no event that fires as the browser drains it. Some Chromium versions support ",[17,16081,16082],{},"Request.duplex: 'half'"," which opens the door to upload streaming, but there's still no ",[17,16085,12881],{}," event. You'd have to instrument your own stream's ",[17,16088,16089],{},"pull()"," to count bytes, and even then you're measuring what the stream emits, not what the socket has sent.",[11,16092,16093],{},"In practice, if you want a progress bar for uploads in 2026, you use XHR.",[25,16095,16097],{"id":16096},"the-five-xhr-events-you-actually-care-about","The five XHR events you actually care about",[11,16099,16100],{},"Every XHR fires a predictable cluster of events. The ones that matter for progress UIs are:",[39,16102,16104],{"className":1888,"code":16103,"language":1890,"meta":44,"style":44},"xhr.upload.onprogress = (e: ProgressEvent) => { \u002F* upload bytes *\u002F };\nxhr.onprogress        = (e: ProgressEvent) => { \u002F* download bytes *\u002F };\nxhr.onload            = () => { \u002F* request completed successfully *\u002F };\nxhr.onerror           = () => { \u002F* network error *\u002F };\nxhr.onabort           = () => { \u002F* xhr.abort() was called *\u002F };\nxhr.ontimeout         = () => { \u002F* xhr.timeout exceeded *\u002F };\n",[17,16105,16106,16136,16165,16186,16207,16227],{"__ignoreMap":44},[48,16107,16108,16111,16114,16116,16118,16120,16122,16125,16127,16129,16131,16134],{"class":50,"line":51},[48,16109,16110],{"class":54},"xhr.upload.",[48,16112,16113],{"class":261},"onprogress",[48,16115,342],{"class":62},[48,16117,2180],{"class":54},[48,16119,13127],{"class":600},[48,16121,336],{"class":62},[48,16123,16124],{"class":261}," ProgressEvent",[48,16126,806],{"class":54},[48,16128,647],{"class":62},[48,16130,700],{"class":54},[48,16132,16133],{"class":2473},"\u002F* upload bytes *\u002F",[48,16135,3954],{"class":54},[48,16137,16138,16141,16143,16146,16148,16150,16152,16154,16156,16158,16160,16163],{"class":50,"line":81},[48,16139,16140],{"class":54},"xhr.",[48,16142,16113],{"class":261},[48,16144,16145],{"class":62},"        =",[48,16147,2180],{"class":54},[48,16149,13127],{"class":600},[48,16151,336],{"class":62},[48,16153,16124],{"class":261},[48,16155,806],{"class":54},[48,16157,647],{"class":62},[48,16159,700],{"class":54},[48,16161,16162],{"class":2473},"\u002F* download bytes *\u002F",[48,16164,3954],{"class":54},[48,16166,16167,16169,16172,16175,16177,16179,16181,16184],{"class":50,"line":87},[48,16168,16140],{"class":54},[48,16170,16171],{"class":261},"onload",[48,16173,16174],{"class":62},"            =",[48,16176,4427],{"class":54},[48,16178,647],{"class":62},[48,16180,700],{"class":54},[48,16182,16183],{"class":2473},"\u002F* request completed successfully *\u002F",[48,16185,3954],{"class":54},[48,16187,16188,16190,16193,16196,16198,16200,16202,16205],{"class":50,"line":93},[48,16189,16140],{"class":54},[48,16191,16192],{"class":261},"onerror",[48,16194,16195],{"class":62},"           =",[48,16197,4427],{"class":54},[48,16199,647],{"class":62},[48,16201,700],{"class":54},[48,16203,16204],{"class":2473},"\u002F* network error *\u002F",[48,16206,3954],{"class":54},[48,16208,16209,16211,16214,16216,16218,16220,16222,16225],{"class":50,"line":362},[48,16210,16140],{"class":54},[48,16212,16213],{"class":261},"onabort",[48,16215,16195],{"class":62},[48,16217,4427],{"class":54},[48,16219,647],{"class":62},[48,16221,700],{"class":54},[48,16223,16224],{"class":2473},"\u002F* xhr.abort() was called *\u002F",[48,16226,3954],{"class":54},[48,16228,16229,16231,16234,16237,16239,16241,16243,16246],{"class":50,"line":373},[48,16230,16140],{"class":54},[48,16232,16233],{"class":261},"ontimeout",[48,16235,16236],{"class":62},"         =",[48,16238,4427],{"class":54},[48,16240,647],{"class":62},[48,16242,700],{"class":54},[48,16244,16245],{"class":2473},"\u002F* xhr.timeout exceeded *\u002F",[48,16247,3954],{"class":54},[11,16249,16250],{},"Two things are easy to miss.",[11,16252,16253,16254,16257,16258,16261,16262,16264,16265,16268,16269,16271],{},"First, ",[17,16255,16256],{},"xhr.upload"," is a separate ",[17,16259,16260],{},"XMLHttpRequestUpload"," object with its own event target. It fires ",[17,16263,12881],{}," as the request body is sent; ",[17,16266,16267],{},"xhr"," itself fires ",[17,16270,12881],{}," as the response body is received. They're the two phases of the same request, and they use the same event shape.",[11,16273,16274,16275,16278,16279,16282,16283,526],{},"Second, ",[17,16276,16277],{},"xhr.onload"," fires on any completed request, including one that returned HTTP 500. \"Request completed\" here means \"the server replied.\" If you want \"the request succeeded,\" check ",[17,16280,16281],{},"xhr.status"," yourself inside ",[17,16284,16171],{},[11,16286,16287,16288,158,16291,158,16294,16297],{},"There are other events (",[17,16289,16290],{},"loadstart",[17,16292,16293],{},"loadend",[17,16295,16296],{},"readystatechange",") but for a progress UI the five above cover everything you need.",[25,16299,2251,16301,16304,16305],{"id":16300},"the-progressevent-shape-and-lengthcomputable",[17,16302,16303],{},"ProgressEvent"," shape, and ",[17,16306,16307],{},"lengthComputable",[11,16309,16310,16311,16313,16314,16316],{},"Both ",[17,16312,16113],{}," callbacks receive a ",[17,16315,16303],{}," with three fields:",[39,16318,16320],{"className":1888,"code":16319,"language":1890,"meta":44,"style":44},"interface ProgressEvent {\n  lengthComputable: boolean;\n  loaded: number;   \u002F\u002F bytes transferred so far\n  total: number;    \u002F\u002F bytes expected, ONLY valid when lengthComputable is true\n}\n",[17,16321,16322,16331,16342,16357,16372],{"__ignoreMap":44},[48,16323,16324,16327,16329],{"class":50,"line":51},[48,16325,16326],{"class":62},"interface",[48,16328,16124],{"class":261},[48,16330,1623],{"class":54},[48,16332,16333,16336,16338,16340],{"class":50,"line":81},[48,16334,16335],{"class":600},"  lengthComputable",[48,16337,336],{"class":62},[48,16339,4664],{"class":332},[48,16341,318],{"class":54},[48,16343,16344,16347,16349,16351,16354],{"class":50,"line":87},[48,16345,16346],{"class":600},"  loaded",[48,16348,336],{"class":62},[48,16350,3769],{"class":332},[48,16352,16353],{"class":54},";   ",[48,16355,16356],{"class":2473},"\u002F\u002F bytes transferred so far\n",[48,16358,16359,16362,16364,16366,16369],{"class":50,"line":93},[48,16360,16361],{"class":600},"  total",[48,16363,336],{"class":62},[48,16365,3769],{"class":332},[48,16367,16368],{"class":54},";    ",[48,16370,16371],{"class":2473},"\u002F\u002F bytes expected, ONLY valid when lengthComputable is true\n",[48,16373,16374],{"class":50,"line":362},[48,16375,1717],{"class":54},[11,16377,16378,16380,16381,16383,16384,16387,16388,16390,16391,16394,16395,16397,16398,16400,16401,3722,16403,16405],{},[17,16379,16307],{}," is the field most first-time implementations forget. It's ",[17,16382,10889],{}," when the server sent a chunked response without a ",[17,16385,16386],{},"Content-Length"," header, and it's ",[17,16389,10889],{}," during uploads where the body is a stream of unknown length. When it's false, ",[17,16392,16393],{},"total"," is ",[17,16396,2840],{},": not \"not provided,\" not ",[17,16399,7113],{},", just the number zero, and dividing by it gives you ",[17,16402,12120],{},[17,16404,12123],{}," in your progress bar.",[11,16407,16408],{},"Wrap the normalization in one place:",[39,16410,16412],{"className":1888,"code":16411,"language":1890,"meta":44,"style":44},"function normaliseProgress(phase: 'upload' | 'download', e: ProgressEvent) {\n  const total = e.lengthComputable ? e.total : null;\n  const ratio = total && total > 0 ? e.loaded \u002F total : null;\n  return { phase, loaded: e.loaded, total, ratio };\n}\n",[17,16413,16414,16446,16468,16503,16510],{"__ignoreMap":44},[48,16415,16416,16418,16421,16423,16426,16428,16431,16433,16436,16438,16440,16442,16444],{"class":50,"line":51},[48,16417,2419],{"class":62},[48,16419,16420],{"class":261}," normaliseProgress",[48,16422,624],{"class":54},[48,16424,16425],{"class":600},"phase",[48,16427,336],{"class":62},[48,16429,16430],{"class":69}," 'upload'",[48,16432,678],{"class":62},[48,16434,16435],{"class":69}," 'download'",[48,16437,158],{"class":54},[48,16439,13127],{"class":600},[48,16441,336],{"class":62},[48,16443,16124],{"class":261},[48,16445,2146],{"class":54},[48,16447,16448,16450,16452,16454,16457,16459,16462,16464,16466],{"class":50,"line":81},[48,16449,2604],{"class":62},[48,16451,15934],{"class":332},[48,16453,342],{"class":62},[48,16455,16456],{"class":54}," e.lengthComputable ",[48,16458,757],{"class":62},[48,16460,16461],{"class":54}," e.total ",[48,16463,336],{"class":62},[48,16465,656],{"class":332},[48,16467,318],{"class":54},[48,16469,16470,16472,16475,16477,16480,16482,16484,16486,16488,16490,16493,16495,16497,16499,16501],{"class":50,"line":87},[48,16471,2604],{"class":62},[48,16473,16474],{"class":332}," ratio",[48,16476,342],{"class":62},[48,16478,16479],{"class":54}," total ",[48,16481,2514],{"class":62},[48,16483,16479],{"class":54},[48,16485,5432],{"class":62},[48,16487,8717],{"class":332},[48,16489,7621],{"class":62},[48,16491,16492],{"class":54}," e.loaded ",[48,16494,4005],{"class":62},[48,16496,16479],{"class":54},[48,16498,336],{"class":62},[48,16500,656],{"class":332},[48,16502,318],{"class":54},[48,16504,16505,16507],{"class":50,"line":93},[48,16506,2963],{"class":62},[48,16508,16509],{"class":54}," { phase, loaded: e.loaded, total, ratio };\n",[48,16511,16512],{"class":50,"line":362},[48,16513,1717],{"class":54},[11,16515,16516,16518,16519,16521],{},[17,16517,171],{}," is the honest answer when you don't know the total. The UI layer decides what to show: bytes counter with no percentage, an indeterminate bar, a spinner. Don't let ",[17,16520,12120],{}," leak out of your transport.",[25,16523,16525],{"id":16524},"a-minimal-upload-progress-example","A minimal upload-progress example",[11,16527,16528],{},"Here's the smallest useful upload with progress, showing the API surface without a wrapper:",[39,16530,16532],{"className":1888,"code":16531,"language":1890,"meta":44,"style":44},"async function uploadWithProgress(\n  file: File,\n  onProgress: (loaded: number, total: number | null) => void\n): Promise\u003Cvoid> {\n  await new Promise\u003Cvoid>((resolve, reject) => {\n    const xhr = new XMLHttpRequest();\n    xhr.open('POST', '\u002Fupload', true);\n\n    xhr.upload.onprogress = (e) => {\n      const total = e.lengthComputable ? e.total : null;\n      onProgress(e.loaded, total);\n    };\n\n    xhr.onload = () => {\n      if (xhr.status >= 200 && xhr.status \u003C 300) resolve();\n      else reject(new Error(`HTTP ${xhr.status}`));\n    };\n    xhr.onerror = () => reject(new TypeError('Network error'));\n    xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'));\n\n    const form = new FormData();\n    form.append('file', file);\n    xhr.send(form);\n  });\n}\n",[17,16533,16534,16545,16557,16591,16605,16633,16649,16672,16676,16695,16715,16723,16727,16731,16745,16772,16803,16807,16835,16868,16872,16888,16904,16914,16918],{"__ignoreMap":44},[48,16535,16536,16538,16540,16543],{"class":50,"line":51},[48,16537,3746],{"class":62},[48,16539,3343],{"class":62},[48,16541,16542],{"class":261}," uploadWithProgress",[48,16544,2111],{"class":54},[48,16546,16547,16550,16552,16555],{"class":50,"line":81},[48,16548,16549],{"class":600},"  file",[48,16551,336],{"class":62},[48,16553,16554],{"class":261}," File",[48,16556,359],{"class":54},[48,16558,16559,16561,16563,16565,16568,16570,16572,16574,16576,16578,16580,16582,16584,16586,16588],{"class":50,"line":87},[48,16560,16057],{"class":261},[48,16562,336],{"class":62},[48,16564,2180],{"class":54},[48,16566,16567],{"class":600},"loaded",[48,16569,336],{"class":62},[48,16571,3769],{"class":332},[48,16573,158],{"class":54},[48,16575,16393],{"class":600},[48,16577,336],{"class":62},[48,16579,3769],{"class":332},[48,16581,678],{"class":62},[48,16583,656],{"class":332},[48,16585,806],{"class":54},[48,16587,647],{"class":62},[48,16589,16590],{"class":332}," void\n",[48,16592,16593,16595,16597,16599,16601,16603],{"class":50,"line":93},[48,16594,2434],{"class":54},[48,16596,336],{"class":62},[48,16598,3366],{"class":261},[48,16600,55],{"class":54},[48,16602,12137],{"class":332},[48,16604,3373],{"class":54},[48,16606,16607,16609,16611,16613,16615,16617,16620,16622,16624,16627,16629,16631],{"class":50,"line":362},[48,16608,15506],{"class":62},[48,16610,3832],{"class":62},[48,16612,3366],{"class":332},[48,16614,55],{"class":54},[48,16616,12137],{"class":332},[48,16618,16619],{"class":54},">((",[48,16621,4229],{"class":600},[48,16623,158],{"class":54},[48,16625,16626],{"class":600},"reject",[48,16628,806],{"class":54},[48,16630,647],{"class":62},[48,16632,1623],{"class":54},[48,16634,16635,16637,16640,16642,16644,16647],{"class":50,"line":373},[48,16636,2201],{"class":62},[48,16638,16639],{"class":332}," xhr",[48,16641,342],{"class":62},[48,16643,3832],{"class":62},[48,16645,16646],{"class":261}," XMLHttpRequest",[48,16648,3473],{"class":54},[48,16650,16651,16654,16656,16658,16661,16663,16666,16668,16670],{"class":50,"line":384},[48,16652,16653],{"class":54},"    xhr.",[48,16655,4281],{"class":261},[48,16657,624],{"class":54},[48,16659,16660],{"class":69},"'POST'",[48,16662,158],{"class":54},[48,16664,16665],{"class":69},"'\u002Fupload'",[48,16667,158],{"class":54},[48,16669,773],{"class":332},[48,16671,629],{"class":54},[48,16673,16674],{"class":50,"line":661},[48,16675,324],{"emptyLinePlaceholder":323},[48,16677,16678,16681,16683,16685,16687,16689,16691,16693],{"class":50,"line":690},[48,16679,16680],{"class":54},"    xhr.upload.",[48,16682,16113],{"class":261},[48,16684,342],{"class":62},[48,16686,2180],{"class":54},[48,16688,13127],{"class":600},[48,16690,806],{"class":54},[48,16692,647],{"class":62},[48,16694,1623],{"class":54},[48,16696,16697,16699,16701,16703,16705,16707,16709,16711,16713],{"class":50,"line":695},[48,16698,2522],{"class":62},[48,16700,15934],{"class":332},[48,16702,342],{"class":62},[48,16704,16456],{"class":54},[48,16706,757],{"class":62},[48,16708,16461],{"class":54},[48,16710,336],{"class":62},[48,16712,656],{"class":332},[48,16714,318],{"class":54},[48,16716,16717,16720],{"class":50,"line":722},[48,16718,16719],{"class":261},"      onProgress",[48,16721,16722],{"class":54},"(e.loaded, total);\n",[48,16724,16725],{"class":50,"line":743},[48,16726,6546],{"class":54},[48,16728,16729],{"class":50,"line":767},[48,16730,324],{"emptyLinePlaceholder":323},[48,16732,16733,16735,16737,16739,16741,16743],{"class":50,"line":778},[48,16734,16653],{"class":54},[48,16736,16171],{"class":261},[48,16738,342],{"class":62},[48,16740,4427],{"class":54},[48,16742,647],{"class":62},[48,16744,1623],{"class":54},[48,16746,16747,16749,16752,16754,16756,16758,16761,16763,16766,16768,16770],{"class":50,"line":783},[48,16748,2151],{"class":62},[48,16750,16751],{"class":54}," (xhr.status ",[48,16753,2809],{"class":62},[48,16755,15588],{"class":332},[48,16757,8707],{"class":62},[48,16759,16760],{"class":54}," xhr.status ",[48,16762,55],{"class":62},[48,16764,16765],{"class":332}," 300",[48,16767,806],{"class":54},[48,16769,4229],{"class":261},[48,16771,3473],{"class":54},[48,16773,16774,16777,16780,16782,16784,16786,16788,16791,16793,16795,16798,16800],{"class":50,"line":788},[48,16775,16776],{"class":62},"      else",[48,16778,16779],{"class":261}," reject",[48,16781,624],{"class":54},[48,16783,11422],{"class":62},[48,16785,13090],{"class":261},[48,16787,624],{"class":54},[48,16789,16790],{"class":69},"`HTTP ${",[48,16792,16267],{"class":54},[48,16794,526],{"class":69},[48,16796,16797],{"class":54},"status",[48,16799,738],{"class":69},[48,16801,16802],{"class":54},"));\n",[48,16804,16805],{"class":50,"line":819},[48,16806,6546],{"class":54},[48,16808,16809,16811,16813,16815,16817,16819,16821,16823,16825,16828,16830,16833],{"class":50,"line":828},[48,16810,16653],{"class":54},[48,16812,16192],{"class":261},[48,16814,342],{"class":62},[48,16816,4427],{"class":54},[48,16818,647],{"class":62},[48,16820,16779],{"class":261},[48,16822,624],{"class":54},[48,16824,11422],{"class":62},[48,16826,16827],{"class":261}," TypeError",[48,16829,624],{"class":54},[48,16831,16832],{"class":69},"'Network error'",[48,16834,16802],{"class":54},[48,16836,16837,16839,16841,16843,16845,16847,16849,16851,16853,16856,16858,16861,16863,16866],{"class":50,"line":833},[48,16838,16653],{"class":54},[48,16840,16213],{"class":261},[48,16842,342],{"class":62},[48,16844,4427],{"class":54},[48,16846,647],{"class":62},[48,16848,16779],{"class":261},[48,16850,624],{"class":54},[48,16852,11422],{"class":62},[48,16854,16855],{"class":261}," DOMException",[48,16857,624],{"class":54},[48,16859,16860],{"class":69},"'Aborted'",[48,16862,158],{"class":54},[48,16864,16865],{"class":69},"'AbortError'",[48,16867,16802],{"class":54},[48,16869,16870],{"class":50,"line":843},[48,16871,324],{"emptyLinePlaceholder":323},[48,16873,16874,16876,16879,16881,16883,16886],{"class":50,"line":860},[48,16875,2201],{"class":62},[48,16877,16878],{"class":332}," form",[48,16880,342],{"class":62},[48,16882,3832],{"class":62},[48,16884,16885],{"class":261}," FormData",[48,16887,3473],{"class":54},[48,16889,16890,16893,16896,16898,16901],{"class":50,"line":887},[48,16891,16892],{"class":54},"    form.",[48,16894,16895],{"class":261},"append",[48,16897,624],{"class":54},[48,16899,16900],{"class":69},"'file'",[48,16902,16903],{"class":54},", file);\n",[48,16905,16906,16908,16911],{"class":50,"line":905},[48,16907,16653],{"class":54},[48,16909,16910],{"class":261},"send",[48,16912,16913],{"class":54},"(form);\n",[48,16915,16916],{"class":50,"line":926},[48,16917,7813],{"class":54},[48,16919,16920],{"class":50,"line":936},[48,16921,1717],{"class":54},[11,16923,16924],{},"Three things worth pointing out:",[8453,16926,16927,16944,16954],{},[3669,16928,16929,16939,16940,16943],{},[35,16930,16931,16932,16935,16936,526],{},"Don't set ",[17,16933,16934],{},"Content-Type"," when sending ",[17,16937,16938],{},"FormData"," The browser needs to pick its own boundary string for multipart encoding. If you set ",[17,16941,16942],{},"Content-Type: multipart\u002Fform-data"," manually, you'll override it without the boundary, and the server will fail to parse the body.",[3669,16945,16946,8473,16951,16953],{},[35,16947,16948,16950],{},[17,16949,16281],{}," check is explicit.",[17,16952,16171],{}," fires for 500s. Treating \"the request completed\" and \"the request succeeded\" as the same thing is the single most common source of silent upload bugs.",[3669,16955,16956,16962,16963,16966],{},[35,16957,16958,16961],{},[17,16959,16960],{},"new Promise"," is the adapter."," XHR is event-based; the rest of your code is ",[17,16964,16965],{},"async\u002Fawait","-shaped. Wrapping it once in a Promise keeps the awkwardness local.",[25,16968,16970],{"id":16969},"a-minimal-download-progress-example","A minimal download-progress example",[11,16972,16973,16974,336],{},"For downloads, the same shape, but on ",[17,16975,16976],{},"xhr.onprogress",[39,16978,16980],{"className":1888,"code":16979,"language":1890,"meta":44,"style":44},"async function downloadWithProgress(\n  url: string,\n  onProgress: (loaded: number, total: number | null) => void\n): Promise\u003CBlob> {\n  return new Promise\u003CBlob>((resolve, reject) => {\n    const xhr = new XMLHttpRequest();\n    xhr.open('GET', url, true);\n    xhr.responseType = 'blob';\n\n    xhr.onprogress = (e) => {\n      const total = e.lengthComputable ? e.total : null;\n      onProgress(e.loaded, total);\n    };\n\n    xhr.onload = () => {\n      if (xhr.status >= 200 && xhr.status \u003C 300) {\n        resolve(xhr.response as Blob);\n      } else {\n        reject(new Error(`HTTP ${xhr.status}`));\n      }\n    };\n    xhr.onerror = () => reject(new TypeError('Network error'));\n\n    xhr.send();\n  });\n}\n",[17,16981,16982,16993,17004,17036,17051,17077,17091,17108,17120,17124,17142,17162,17168,17172,17176,17190,17210,17225,17234,17259,17263,17267,17293,17297,17305,17309],{"__ignoreMap":44},[48,16983,16984,16986,16988,16991],{"class":50,"line":51},[48,16985,3746],{"class":62},[48,16987,3343],{"class":62},[48,16989,16990],{"class":261}," downloadWithProgress",[48,16992,2111],{"class":54},[48,16994,16995,16998,17000,17002],{"class":50,"line":81},[48,16996,16997],{"class":600},"  url",[48,16999,336],{"class":62},[48,17001,606],{"class":332},[48,17003,359],{"class":54},[48,17005,17006,17008,17010,17012,17014,17016,17018,17020,17022,17024,17026,17028,17030,17032,17034],{"class":50,"line":87},[48,17007,16057],{"class":261},[48,17009,336],{"class":62},[48,17011,2180],{"class":54},[48,17013,16567],{"class":600},[48,17015,336],{"class":62},[48,17017,3769],{"class":332},[48,17019,158],{"class":54},[48,17021,16393],{"class":600},[48,17023,336],{"class":62},[48,17025,3769],{"class":332},[48,17027,678],{"class":62},[48,17029,656],{"class":332},[48,17031,806],{"class":54},[48,17033,647],{"class":62},[48,17035,16590],{"class":332},[48,17037,17038,17040,17042,17044,17046,17049],{"class":50,"line":93},[48,17039,2434],{"class":54},[48,17041,336],{"class":62},[48,17043,3366],{"class":261},[48,17045,55],{"class":54},[48,17047,17048],{"class":261},"Blob",[48,17050,3373],{"class":54},[48,17052,17053,17055,17057,17059,17061,17063,17065,17067,17069,17071,17073,17075],{"class":50,"line":362},[48,17054,2963],{"class":62},[48,17056,3832],{"class":62},[48,17058,3366],{"class":332},[48,17060,55],{"class":54},[48,17062,17048],{"class":261},[48,17064,16619],{"class":54},[48,17066,4229],{"class":600},[48,17068,158],{"class":54},[48,17070,16626],{"class":600},[48,17072,806],{"class":54},[48,17074,647],{"class":62},[48,17076,1623],{"class":54},[48,17078,17079,17081,17083,17085,17087,17089],{"class":50,"line":373},[48,17080,2201],{"class":62},[48,17082,16639],{"class":332},[48,17084,342],{"class":62},[48,17086,3832],{"class":62},[48,17088,16646],{"class":261},[48,17090,3473],{"class":54},[48,17092,17093,17095,17097,17099,17101,17104,17106],{"class":50,"line":384},[48,17094,16653],{"class":54},[48,17096,4281],{"class":261},[48,17098,624],{"class":54},[48,17100,12747],{"class":69},[48,17102,17103],{"class":54},", url, ",[48,17105,773],{"class":332},[48,17107,629],{"class":54},[48,17109,17110,17113,17115,17118],{"class":50,"line":661},[48,17111,17112],{"class":54},"    xhr.responseType ",[48,17114,66],{"class":62},[48,17116,17117],{"class":69}," 'blob'",[48,17119,318],{"class":54},[48,17121,17122],{"class":50,"line":690},[48,17123,324],{"emptyLinePlaceholder":323},[48,17125,17126,17128,17130,17132,17134,17136,17138,17140],{"class":50,"line":695},[48,17127,16653],{"class":54},[48,17129,16113],{"class":261},[48,17131,342],{"class":62},[48,17133,2180],{"class":54},[48,17135,13127],{"class":600},[48,17137,806],{"class":54},[48,17139,647],{"class":62},[48,17141,1623],{"class":54},[48,17143,17144,17146,17148,17150,17152,17154,17156,17158,17160],{"class":50,"line":722},[48,17145,2522],{"class":62},[48,17147,15934],{"class":332},[48,17149,342],{"class":62},[48,17151,16456],{"class":54},[48,17153,757],{"class":62},[48,17155,16461],{"class":54},[48,17157,336],{"class":62},[48,17159,656],{"class":332},[48,17161,318],{"class":54},[48,17163,17164,17166],{"class":50,"line":743},[48,17165,16719],{"class":261},[48,17167,16722],{"class":54},[48,17169,17170],{"class":50,"line":767},[48,17171,6546],{"class":54},[48,17173,17174],{"class":50,"line":778},[48,17175,324],{"emptyLinePlaceholder":323},[48,17177,17178,17180,17182,17184,17186,17188],{"class":50,"line":783},[48,17179,16653],{"class":54},[48,17181,16171],{"class":261},[48,17183,342],{"class":62},[48,17185,4427],{"class":54},[48,17187,647],{"class":62},[48,17189,1623],{"class":54},[48,17191,17192,17194,17196,17198,17200,17202,17204,17206,17208],{"class":50,"line":788},[48,17193,2151],{"class":62},[48,17195,16751],{"class":54},[48,17197,2809],{"class":62},[48,17199,15588],{"class":332},[48,17201,8707],{"class":62},[48,17203,16760],{"class":54},[48,17205,55],{"class":62},[48,17207,16765],{"class":332},[48,17209,2146],{"class":54},[48,17211,17212,17215,17218,17220,17223],{"class":50,"line":819},[48,17213,17214],{"class":261},"        resolve",[48,17216,17217],{"class":54},"(xhr.response ",[48,17219,2658],{"class":62},[48,17221,17222],{"class":261}," Blob",[48,17224,629],{"class":54},[48,17226,17227,17230,17232],{"class":50,"line":828},[48,17228,17229],{"class":54},"      } ",[48,17231,9224],{"class":62},[48,17233,1623],{"class":54},[48,17235,17236,17239,17241,17243,17245,17247,17249,17251,17253,17255,17257],{"class":50,"line":833},[48,17237,17238],{"class":261},"        reject",[48,17240,624],{"class":54},[48,17242,11422],{"class":62},[48,17244,13090],{"class":261},[48,17246,624],{"class":54},[48,17248,16790],{"class":69},[48,17250,16267],{"class":54},[48,17252,526],{"class":69},[48,17254,16797],{"class":54},[48,17256,738],{"class":69},[48,17258,16802],{"class":54},[48,17260,17261],{"class":50,"line":843},[48,17262,2566],{"class":54},[48,17264,17265],{"class":50,"line":860},[48,17266,6546],{"class":54},[48,17268,17269,17271,17273,17275,17277,17279,17281,17283,17285,17287,17289,17291],{"class":50,"line":887},[48,17270,16653],{"class":54},[48,17272,16192],{"class":261},[48,17274,342],{"class":62},[48,17276,4427],{"class":54},[48,17278,647],{"class":62},[48,17280,16779],{"class":261},[48,17282,624],{"class":54},[48,17284,11422],{"class":62},[48,17286,16827],{"class":261},[48,17288,624],{"class":54},[48,17290,16832],{"class":69},[48,17292,16802],{"class":54},[48,17294,17295],{"class":50,"line":905},[48,17296,324],{"emptyLinePlaceholder":323},[48,17298,17299,17301,17303],{"class":50,"line":926},[48,17300,16653],{"class":54},[48,17302,16910],{"class":261},[48,17304,3473],{"class":54},[48,17306,17307],{"class":50,"line":936},[48,17308,7813],{"class":54},[48,17310,17311],{"class":50,"line":2726},[48,17312,1717],{"class":54},[11,17314,17315,17318,17319,17322,17323,3722,17326,17329,17330,8473,17333,526],{},[17,17316,17317],{},"xhr.responseType = 'blob'"," is the critical line for file downloads. Without it, you get text, and for binary content that means the browser decodes bytes as UTF-8 and hands you garbage. For JSON you'd use ",[17,17320,17321],{},"'json'",", for arbitrary bytes ",[17,17324,17325],{},"'arraybuffer'",[17,17327,17328],{},"'blob'",". Set it ",[2087,17331,17332],{},"before",[17,17334,17335],{},"send()",[11,17337,17338,17339,17341,17342,17344,17345,17347,17348,17350,17351,17353],{},"The server has to send a ",[17,17340,16386],{}," header if you want a meaningful ",[17,17343,16393],{},". Chunked transfer encoding doesn't include one, and ",[17,17346,16307],{}," will be ",[17,17349,10889],{},". CDNs that gzip on the fly also sometimes strip ",[17,17352,16386],{},". When that happens, you're stuck with a bytes counter and no percentage. Which is honest.",[25,17355,17357,17358],{"id":17356},"wrapping-xhr-to-look-like-fetch","Wrapping XHR to look like ",[17,17359,12870],{},[11,17361,17362,17363,17365,17366,17368],{},"Most of a modern codebase is ",[17,17364,12870],{},"-shaped. If you do real progress in XHR but everything else in ",[17,17367,12870],{},", you end up with two parallel request pipelines: different error types, different abort semantics, different header normalization.",[11,17370,17371,17372,17374,17375,17380],{},"The trick is to let ",[17,17373,12870],{}," be the default and swap in an XHR-backed function ",[2087,17376,17377,17378],{},"that returns a ",[17,17379,13933],{}," when progress is needed. The calling code doesn't change.",[11,17382,17383],{},"Here's a wrapper that does exactly that (this is the real implementation from my api-provider package):",[39,17385,17387],{"className":1888,"code":17386,"language":1890,"meta":44,"style":44},"export function createXhrFetch(\n  onProgress: (progress: RequestProgress) => void\n): (input: string, init: RequestInit) => Promise\u003CResponse> {\n  return function xhrFetch(input, init) {\n    const method = (init.method ?? 'GET').toUpperCase();\n    const body = (init.body ?? null) as XMLHttpRequestBodyInit | null;\n    const headers = init.headers as Record\u003Cstring, string> | undefined;\n    const signal = init.signal as AbortSignal | null | undefined;\n\n    return new Promise\u003CResponse>((resolve, reject) => {\n      if (typeof XMLHttpRequest === 'undefined') {\n        reject(new Error('XHR unavailable in this runtime'));\n        return;\n      }\n\n      const xhr = new XMLHttpRequest();\n      xhr.open(method, input, true);\n      xhr.responseType = 'blob';\n      if (init.credentials === 'include') xhr.withCredentials = true;\n\n      if (headers) {\n        for (const [name, value] of Object.entries(headers)) {\n          try { xhr.setRequestHeader(name, value); }\n          catch { \u002F* forbidden headers are silently skipped *\u002F }\n        }\n      }\n\n      xhr.upload.onprogress = (e) => onProgress(normaliseProgress('upload', e));\n      xhr.onprogress        = (e) => onProgress(normaliseProgress('download', e));\n\n      const onAbort = () => xhr.abort();\n      if (signal) {\n        if (signal.aborted) {\n          reject(new DOMException('Aborted', 'AbortError'));\n          return;\n        }\n        signal.addEventListener('abort', onAbort, { once: true });\n      }\n\n      xhr.onload = () => {\n        if (signal) signal.removeEventListener('abort', onAbort);\n        resolve(new Response(xhr.response as BodyInit | null, {\n          status: xhr.status,\n          statusText: xhr.statusText,\n          headers: parseHeaders(xhr.getAllResponseHeaders()),\n        }));\n      };\n      xhr.onerror   = () => reject(new TypeError('Network error'));\n      xhr.onabort   = () => reject(new DOMException('Aborted', 'AbortError'));\n      xhr.ontimeout = () => reject(new DOMException('Request timeout', 'TimeoutError'));\n\n      xhr.send(body);\n    });\n  };\n}\n",[17,17388,17389,17399,17420,17455,17474,17497,17526,17557,17582,17586,17612,17629,17646,17652,17656,17660,17674,17688,17699,17720,17724,17731,17761,17775,17787,17791,17795,17799,17832,17861,17865,17884,17891,17898,17919,17925,17929,17946,17950,17954,17968,17985,18008,18013,18018,18035,18041,18047,18075,18106,18139,18144,18154,18159,18164],{"__ignoreMap":44},[48,17390,17391,17393,17395,17397],{"class":50,"line":51},[48,17392,3337],{"class":62},[48,17394,3343],{"class":62},[48,17396,12911],{"class":261},[48,17398,2111],{"class":54},[48,17400,17401,17403,17405,17407,17409,17411,17414,17416,17418],{"class":50,"line":81},[48,17402,16057],{"class":261},[48,17404,336],{"class":62},[48,17406,2180],{"class":54},[48,17408,12881],{"class":600},[48,17410,336],{"class":62},[48,17412,17413],{"class":261}," RequestProgress",[48,17415,806],{"class":54},[48,17417,647],{"class":62},[48,17419,16590],{"class":332},[48,17421,17422,17424,17426,17428,17431,17433,17435,17437,17439,17441,17443,17445,17447,17449,17451,17453],{"class":50,"line":87},[48,17423,2434],{"class":54},[48,17425,336],{"class":62},[48,17427,2180],{"class":54},[48,17429,17430],{"class":600},"input",[48,17432,336],{"class":62},[48,17434,606],{"class":332},[48,17436,158],{"class":54},[48,17438,14199],{"class":600},[48,17440,336],{"class":62},[48,17442,14204],{"class":261},[48,17444,806],{"class":54},[48,17446,647],{"class":62},[48,17448,3366],{"class":261},[48,17450,55],{"class":54},[48,17452,13933],{"class":261},[48,17454,3373],{"class":54},[48,17456,17457,17459,17461,17464,17466,17468,17470,17472],{"class":50,"line":93},[48,17458,2963],{"class":62},[48,17460,3343],{"class":62},[48,17462,17463],{"class":261}," xhrFetch",[48,17465,624],{"class":54},[48,17467,17430],{"class":600},[48,17469,158],{"class":54},[48,17471,14199],{"class":600},[48,17473,2146],{"class":54},[48,17475,17476,17478,17481,17483,17486,17488,17491,17493,17495],{"class":50,"line":362},[48,17477,2201],{"class":62},[48,17479,17480],{"class":332}," method",[48,17482,342],{"class":62},[48,17484,17485],{"class":54}," (init.method ",[48,17487,2556],{"class":62},[48,17489,17490],{"class":69}," 'GET'",[48,17492,1251],{"class":54},[48,17494,3517],{"class":261},[48,17496,3473],{"class":54},[48,17498,17499,17501,17504,17506,17509,17511,17513,17515,17517,17520,17522,17524],{"class":50,"line":373},[48,17500,2201],{"class":62},[48,17502,17503],{"class":332}," body",[48,17505,342],{"class":62},[48,17507,17508],{"class":54}," (init.body ",[48,17510,2556],{"class":62},[48,17512,656],{"class":332},[48,17514,806],{"class":54},[48,17516,2658],{"class":62},[48,17518,17519],{"class":261}," XMLHttpRequestBodyInit",[48,17521,678],{"class":62},[48,17523,656],{"class":332},[48,17525,318],{"class":54},[48,17527,17528,17530,17532,17534,17537,17539,17541,17543,17545,17547,17549,17551,17553,17555],{"class":50,"line":384},[48,17529,2201],{"class":62},[48,17531,14448],{"class":332},[48,17533,342],{"class":62},[48,17535,17536],{"class":54}," init.headers ",[48,17538,2658],{"class":62},[48,17540,5444],{"class":261},[48,17542,55],{"class":54},[48,17544,1909],{"class":332},[48,17546,158],{"class":54},[48,17548,1909],{"class":332},[48,17550,5531],{"class":54},[48,17552,5422],{"class":62},[48,17554,5429],{"class":332},[48,17556,318],{"class":54},[48,17558,17559,17561,17563,17565,17568,17570,17572,17574,17576,17578,17580],{"class":50,"line":661},[48,17560,2201],{"class":62},[48,17562,14589],{"class":332},[48,17564,342],{"class":62},[48,17566,17567],{"class":54}," init.signal ",[48,17569,2658],{"class":62},[48,17571,14643],{"class":261},[48,17573,678],{"class":62},[48,17575,656],{"class":332},[48,17577,678],{"class":62},[48,17579,5429],{"class":332},[48,17581,318],{"class":54},[48,17583,17584],{"class":50,"line":690},[48,17585,324],{"emptyLinePlaceholder":323},[48,17587,17588,17590,17592,17594,17596,17598,17600,17602,17604,17606,17608,17610],{"class":50,"line":695},[48,17589,2946],{"class":62},[48,17591,3832],{"class":62},[48,17593,3366],{"class":332},[48,17595,55],{"class":54},[48,17597,13933],{"class":261},[48,17599,16619],{"class":54},[48,17601,4229],{"class":600},[48,17603,158],{"class":54},[48,17605,16626],{"class":600},[48,17607,806],{"class":54},[48,17609,647],{"class":62},[48,17611,1623],{"class":54},[48,17613,17614,17616,17618,17620,17623,17625,17627],{"class":50,"line":722},[48,17615,2151],{"class":62},[48,17617,2180],{"class":54},[48,17619,7786],{"class":62},[48,17621,17622],{"class":54}," XMLHttpRequest ",[48,17624,653],{"class":62},[48,17626,8249],{"class":69},[48,17628,2146],{"class":54},[48,17630,17631,17633,17635,17637,17639,17641,17644],{"class":50,"line":743},[48,17632,17238],{"class":261},[48,17634,624],{"class":54},[48,17636,11422],{"class":62},[48,17638,13090],{"class":261},[48,17640,624],{"class":54},[48,17642,17643],{"class":69},"'XHR unavailable in this runtime'",[48,17645,16802],{"class":54},[48,17647,17648,17650],{"class":50,"line":767},[48,17649,2545],{"class":62},[48,17651,318],{"class":54},[48,17653,17654],{"class":50,"line":778},[48,17655,2566],{"class":54},[48,17657,17658],{"class":50,"line":783},[48,17659,324],{"emptyLinePlaceholder":323},[48,17661,17662,17664,17666,17668,17670,17672],{"class":50,"line":788},[48,17663,2522],{"class":62},[48,17665,16639],{"class":332},[48,17667,342],{"class":62},[48,17669,3832],{"class":62},[48,17671,16646],{"class":261},[48,17673,3473],{"class":54},[48,17675,17676,17679,17681,17684,17686],{"class":50,"line":819},[48,17677,17678],{"class":54},"      xhr.",[48,17680,4281],{"class":261},[48,17682,17683],{"class":54},"(method, input, ",[48,17685,773],{"class":332},[48,17687,629],{"class":54},[48,17689,17690,17693,17695,17697],{"class":50,"line":828},[48,17691,17692],{"class":54},"      xhr.responseType ",[48,17694,66],{"class":62},[48,17696,17117],{"class":69},[48,17698,318],{"class":54},[48,17700,17701,17703,17706,17708,17711,17714,17716,17718],{"class":50,"line":833},[48,17702,2151],{"class":62},[48,17704,17705],{"class":54}," (init.credentials ",[48,17707,653],{"class":62},[48,17709,17710],{"class":69}," 'include'",[48,17712,17713],{"class":54},") xhr.withCredentials ",[48,17715,66],{"class":62},[48,17717,3167],{"class":332},[48,17719,318],{"class":54},[48,17721,17722],{"class":50,"line":843},[48,17723,324],{"emptyLinePlaceholder":323},[48,17725,17726,17728],{"class":50,"line":860},[48,17727,2151],{"class":62},[48,17729,17730],{"class":54}," (headers) {\n",[48,17732,17733,17736,17738,17740,17742,17745,17747,17749,17751,17754,17756,17758],{"class":50,"line":887},[48,17734,17735],{"class":62},"        for",[48,17737,2180],{"class":54},[48,17739,329],{"class":62},[48,17741,7432],{"class":54},[48,17743,17744],{"class":332},"name",[48,17746,158],{"class":54},[48,17748,4878],{"class":332},[48,17750,13105],{"class":54},[48,17752,17753],{"class":62},"of",[48,17755,6053],{"class":54},[48,17757,6056],{"class":261},[48,17759,17760],{"class":54},"(headers)) {\n",[48,17762,17763,17766,17769,17772],{"class":50,"line":905},[48,17764,17765],{"class":62},"          try",[48,17767,17768],{"class":54}," { xhr.",[48,17770,17771],{"class":261},"setRequestHeader",[48,17773,17774],{"class":54},"(name, value); }\n",[48,17776,17777,17780,17782,17785],{"class":50,"line":926},[48,17778,17779],{"class":62},"          catch",[48,17781,700],{"class":54},[48,17783,17784],{"class":2473},"\u002F* forbidden headers are silently skipped *\u002F",[48,17786,2244],{"class":54},[48,17788,17789],{"class":50,"line":936},[48,17790,2729],{"class":54},[48,17792,17793],{"class":50,"line":2726},[48,17794,2566],{"class":54},[48,17796,17797],{"class":50,"line":2732},[48,17798,324],{"emptyLinePlaceholder":323},[48,17800,17801,17804,17806,17808,17810,17812,17814,17816,17819,17821,17824,17826,17829],{"class":50,"line":2737},[48,17802,17803],{"class":54},"      xhr.upload.",[48,17805,16113],{"class":261},[48,17807,342],{"class":62},[48,17809,2180],{"class":54},[48,17811,13127],{"class":600},[48,17813,806],{"class":54},[48,17815,647],{"class":62},[48,17817,17818],{"class":261}," onProgress",[48,17820,624],{"class":54},[48,17822,17823],{"class":261},"normaliseProgress",[48,17825,624],{"class":54},[48,17827,17828],{"class":69},"'upload'",[48,17830,17831],{"class":54},", e));\n",[48,17833,17834,17836,17838,17840,17842,17844,17846,17848,17850,17852,17854,17856,17859],{"class":50,"line":2747},[48,17835,17678],{"class":54},[48,17837,16113],{"class":261},[48,17839,16145],{"class":62},[48,17841,2180],{"class":54},[48,17843,13127],{"class":600},[48,17845,806],{"class":54},[48,17847,647],{"class":62},[48,17849,17818],{"class":261},[48,17851,624],{"class":54},[48,17853,17823],{"class":261},[48,17855,624],{"class":54},[48,17857,17858],{"class":69},"'download'",[48,17860,17831],{"class":54},[48,17862,17863],{"class":50,"line":2753},[48,17864,324],{"emptyLinePlaceholder":323},[48,17866,17867,17869,17871,17873,17875,17877,17880,17882],{"class":50,"line":2758},[48,17868,2522],{"class":62},[48,17870,14745],{"class":261},[48,17872,342],{"class":62},[48,17874,4427],{"class":54},[48,17876,647],{"class":62},[48,17878,17879],{"class":54}," xhr.",[48,17881,3859],{"class":261},[48,17883,3473],{"class":54},[48,17885,17886,17888],{"class":50,"line":2763},[48,17887,2151],{"class":62},[48,17889,17890],{"class":54}," (signal) {\n",[48,17892,17893,17895],{"class":50,"line":2768},[48,17894,2702],{"class":62},[48,17896,17897],{"class":54}," (signal.aborted) {\n",[48,17899,17900,17903,17905,17907,17909,17911,17913,17915,17917],{"class":50,"line":2774},[48,17901,17902],{"class":261},"          reject",[48,17904,624],{"class":54},[48,17906,11422],{"class":62},[48,17908,16855],{"class":261},[48,17910,624],{"class":54},[48,17912,16860],{"class":69},[48,17914,158],{"class":54},[48,17916,16865],{"class":69},[48,17918,16802],{"class":54},[48,17920,17921,17923],{"class":50,"line":2823},[48,17922,2710],{"class":62},[48,17924,318],{"class":54},[48,17926,17927],{"class":50,"line":2846},[48,17928,2729],{"class":54},[48,17930,17931,17934,17936,17938,17940,17942,17944],{"class":50,"line":2862},[48,17932,17933],{"class":54},"        signal.",[48,17935,11699],{"class":261},[48,17937,624],{"class":54},[48,17939,14769],{"class":69},[48,17941,14772],{"class":54},[48,17943,773],{"class":332},[48,17945,1922],{"class":54},[48,17947,17948],{"class":50,"line":2883},[48,17949,2566],{"class":54},[48,17951,17952],{"class":50,"line":2904},[48,17953,324],{"emptyLinePlaceholder":323},[48,17955,17956,17958,17960,17962,17964,17966],{"class":50,"line":2931},[48,17957,17678],{"class":54},[48,17959,16171],{"class":261},[48,17961,342],{"class":62},[48,17963,4427],{"class":54},[48,17965,647],{"class":62},[48,17967,1623],{"class":54},[48,17969,17970,17972,17975,17978,17980,17982],{"class":50,"line":2937},[48,17971,2702],{"class":62},[48,17973,17974],{"class":54}," (signal) signal.",[48,17976,17977],{"class":261},"removeEventListener",[48,17979,624],{"class":54},[48,17981,14769],{"class":69},[48,17983,17984],{"class":54},", onAbort);\n",[48,17986,17987,17989,17991,17993,17995,17997,17999,18002,18004,18006],{"class":50,"line":2943},[48,17988,17214],{"class":261},[48,17990,624],{"class":54},[48,17992,11422],{"class":62},[48,17994,14903],{"class":261},[48,17996,17217],{"class":54},[48,17998,2658],{"class":62},[48,18000,18001],{"class":261}," BodyInit",[48,18003,678],{"class":62},[48,18005,656],{"class":332},[48,18007,7768],{"class":54},[48,18009,18010],{"class":50,"line":2955},[48,18011,18012],{"class":54},"          status: xhr.status,\n",[48,18014,18015],{"class":50,"line":2960},[48,18016,18017],{"class":54},"          statusText: xhr.statusText,\n",[48,18019,18020,18023,18026,18029,18032],{"class":50,"line":2970},[48,18021,18022],{"class":54},"          headers: ",[48,18024,18025],{"class":261},"parseHeaders",[48,18027,18028],{"class":54},"(xhr.",[48,18030,18031],{"class":261},"getAllResponseHeaders",[48,18033,18034],{"class":54},"()),\n",[48,18036,18038],{"class":50,"line":18037},46,[48,18039,18040],{"class":54},"        }));\n",[48,18042,18044],{"class":50,"line":18043},47,[48,18045,18046],{"class":54},"      };\n",[48,18048,18050,18052,18054,18057,18059,18061,18063,18065,18067,18069,18071,18073],{"class":50,"line":18049},48,[48,18051,17678],{"class":54},[48,18053,16192],{"class":261},[48,18055,18056],{"class":62},"   =",[48,18058,4427],{"class":54},[48,18060,647],{"class":62},[48,18062,16779],{"class":261},[48,18064,624],{"class":54},[48,18066,11422],{"class":62},[48,18068,16827],{"class":261},[48,18070,624],{"class":54},[48,18072,16832],{"class":69},[48,18074,16802],{"class":54},[48,18076,18078,18080,18082,18084,18086,18088,18090,18092,18094,18096,18098,18100,18102,18104],{"class":50,"line":18077},49,[48,18079,17678],{"class":54},[48,18081,16213],{"class":261},[48,18083,18056],{"class":62},[48,18085,4427],{"class":54},[48,18087,647],{"class":62},[48,18089,16779],{"class":261},[48,18091,624],{"class":54},[48,18093,11422],{"class":62},[48,18095,16855],{"class":261},[48,18097,624],{"class":54},[48,18099,16860],{"class":69},[48,18101,158],{"class":54},[48,18103,16865],{"class":69},[48,18105,16802],{"class":54},[48,18107,18109,18111,18113,18115,18117,18119,18121,18123,18125,18127,18129,18132,18134,18137],{"class":50,"line":18108},50,[48,18110,17678],{"class":54},[48,18112,16233],{"class":261},[48,18114,342],{"class":62},[48,18116,4427],{"class":54},[48,18118,647],{"class":62},[48,18120,16779],{"class":261},[48,18122,624],{"class":54},[48,18124,11422],{"class":62},[48,18126,16855],{"class":261},[48,18128,624],{"class":54},[48,18130,18131],{"class":69},"'Request timeout'",[48,18133,158],{"class":54},[48,18135,18136],{"class":69},"'TimeoutError'",[48,18138,16802],{"class":54},[48,18140,18142],{"class":50,"line":18141},51,[48,18143,324],{"emptyLinePlaceholder":323},[48,18145,18147,18149,18151],{"class":50,"line":18146},52,[48,18148,17678],{"class":54},[48,18150,16910],{"class":261},[48,18152,18153],{"class":54},"(body);\n",[48,18155,18157],{"class":50,"line":18156},53,[48,18158,12428],{"class":54},[48,18160,18162],{"class":50,"line":18161},54,[48,18163,12680],{"class":54},[48,18165,18167],{"class":50,"line":18166},55,[48,18168,1717],{"class":54},[11,18170,18171],{},"Four things are doing real work in this shape:",[11,18173,2251,18174,18179,18180,18183],{},[35,18175,18176,18177],{},"return type is ",[17,18178,14013],{},". That's the whole point: anything that accepts ",[17,18181,18182],{},"typeof fetch"," accepts this function. The rest of the codebase (interceptors, retry, JSON parsing, error mapping) stays transport-agnostic.",[11,18185,2251,18186,18189,18190,18193,18194,18197,18198,18200,18201,336],{},[35,18187,18188],{},"header normalizer at the bottom"," translates ",[17,18191,18192],{},"xhr.getAllResponseHeaders()"," (a raw CRLF-separated string) into a ",[17,18195,18196],{},"Headers"," object so the ",[17,18199,13933],{}," behaves exactly like one from ",[17,18202,12870],{},[39,18204,18206],{"className":1888,"code":18205,"language":1890,"meta":44,"style":44},"function parseHeaders(raw: string): Headers {\n  const out = new Headers();\n  if (!raw) return out;\n  for (const line of raw.trim().split(\u002F[\\r\\n]+\u002F)) {\n    const idx = line.indexOf(':');\n    if (idx \u003C 0) continue;\n    const name = line.slice(0, idx).trim();\n    const value = line.slice(idx + 1).trim();\n    if (name) out.append(name, value);\n  }\n  return out;\n}\n",[17,18207,18208,18232,18247,18263,18299,18320,18337,18360,18385,18397,18401,18407],{"__ignoreMap":44},[48,18209,18210,18212,18215,18217,18219,18221,18223,18225,18227,18230],{"class":50,"line":51},[48,18211,2419],{"class":62},[48,18213,18214],{"class":261}," parseHeaders",[48,18216,624],{"class":54},[48,18218,5760],{"class":600},[48,18220,336],{"class":62},[48,18222,606],{"class":332},[48,18224,2434],{"class":54},[48,18226,336],{"class":62},[48,18228,18229],{"class":261}," Headers",[48,18231,1623],{"class":54},[48,18233,18234,18236,18239,18241,18243,18245],{"class":50,"line":81},[48,18235,2604],{"class":62},[48,18237,18238],{"class":332}," out",[48,18240,342],{"class":62},[48,18242,3832],{"class":62},[48,18244,18229],{"class":261},[48,18246,3473],{"class":54},[48,18248,18249,18251,18253,18255,18258,18260],{"class":50,"line":87},[48,18250,2450],{"class":62},[48,18252,2180],{"class":54},[48,18254,2455],{"class":62},[48,18256,18257],{"class":54},"raw) ",[48,18259,2194],{"class":62},[48,18261,18262],{"class":54}," out;\n",[48,18264,18265,18267,18269,18271,18274,18276,18279,18281,18283,18285,18287,18289,18292,18294,18296],{"class":50,"line":93},[48,18266,2777],{"class":62},[48,18268,2180],{"class":54},[48,18270,329],{"class":62},[48,18272,18273],{"class":332}," line",[48,18275,15130],{"class":62},[48,18277,18278],{"class":54}," raw.",[48,18280,5804],{"class":261},[48,18282,3983],{"class":54},[48,18284,7971],{"class":261},[48,18286,624],{"class":54},[48,18288,4005],{"class":69},[48,18290,18291],{"class":332},"[\\r\\n]",[48,18293,2394],{"class":62},[48,18295,4005],{"class":69},[48,18297,18298],{"class":54},")) {\n",[48,18300,18301,18303,18306,18308,18311,18313,18315,18318],{"class":50,"line":362},[48,18302,2201],{"class":62},[48,18304,18305],{"class":332}," idx",[48,18307,342],{"class":62},[48,18309,18310],{"class":54}," line.",[48,18312,15291],{"class":261},[48,18314,624],{"class":54},[48,18316,18317],{"class":69},"':'",[48,18319,629],{"class":54},[48,18321,18322,18324,18327,18329,18331,18333,18335],{"class":50,"line":373},[48,18323,2135],{"class":62},[48,18325,18326],{"class":54}," (idx ",[48,18328,55],{"class":62},[48,18330,8717],{"class":332},[48,18332,806],{"class":54},[48,18334,2878],{"class":62},[48,18336,318],{"class":54},[48,18338,18339,18341,18343,18345,18347,18349,18351,18353,18356,18358],{"class":50,"line":384},[48,18340,2201],{"class":62},[48,18342,5068],{"class":332},[48,18344,342],{"class":62},[48,18346,18310],{"class":54},[48,18348,2835],{"class":261},[48,18350,624],{"class":54},[48,18352,2840],{"class":332},[48,18354,18355],{"class":54},", idx).",[48,18357,5804],{"class":261},[48,18359,3473],{"class":54},[48,18361,18362,18364,18366,18368,18370,18372,18375,18377,18379,18381,18383],{"class":50,"line":661},[48,18363,2201],{"class":62},[48,18365,11912],{"class":332},[48,18367,342],{"class":62},[48,18369,18310],{"class":54},[48,18371,2835],{"class":261},[48,18373,18374],{"class":54},"(idx ",[48,18376,2394],{"class":62},[48,18378,2812],{"class":332},[48,18380,1251],{"class":54},[48,18382,5804],{"class":261},[48,18384,3473],{"class":54},[48,18386,18387,18389,18392,18394],{"class":50,"line":690},[48,18388,2135],{"class":62},[48,18390,18391],{"class":54}," (name) out.",[48,18393,16895],{"class":261},[48,18395,18396],{"class":54},"(name, value);\n",[48,18398,18399],{"class":50,"line":695},[48,18400,2590],{"class":54},[48,18402,18403,18405],{"class":50,"line":722},[48,18404,2963],{"class":62},[48,18406,18262],{"class":54},[48,18408,18409],{"class":50,"line":743},[48,18410,1717],{"class":54},[11,18412,2251,18413,18416,18417,18419,18420,18423,18424,18427,18428,18431],{},[35,18414,18415],{},"AbortSignal bridge"," converts between ",[17,18418,12870],{},"'s cancellation primitive and XHR's ",[17,18421,18422],{},"abort()"," method. ",[17,18425,18426],{},"AbortSignal.addEventListener('abort', xhr.abort)"," makes the two APIs speak the same language; checking ",[17,18429,18430],{},"signal.aborted"," up front handles the case where the caller passes an already-aborted signal (which happens, for example, when you cancel a request before it's sent).",[11,18433,2251,18434,18441,18442,158,18445,158,18448,18451,18452,1294,18455,18458,18459,18461,18462,18464],{},[35,18435,18436,18438,18439],{},[17,18437,14819],{}," around ",[17,18440,17771],{}," is deliberate. Browsers forbid certain headers (",[17,18443,18444],{},"User-Agent",[17,18446,18447],{},"Cookie",[17,18449,18450],{},"Host",", most ",[17,18453,18454],{},"Sec-*",[17,18456,18457],{},"Proxy-*"," variants). ",[17,18460,12870],{}," silently ignores them too, and the ",[17,18463,14819],{}," makes XHR match the behavior.",[25,18466,18468],{"id":18467},"aborting-timing-out-and-erroring","Aborting, timing out, and erroring",[11,18470,18471,18472,158,18474,158,18476,158,18478,18480,18481,18483],{},"The four terminal outcomes of an XHR are ",[17,18473,16171],{},[17,18475,16192],{},[17,18477,16213],{},[17,18479,16233],{},". They're mutually exclusive: exactly one fires per request. The translation to ",[17,18482,12870],{},"-world:",[1514,18485,18486,18499],{},[1517,18487,18488],{},[1520,18489,18490,18493,18496],{},[1523,18491,18492],{},"XHR event",[1523,18494,18495],{},"Meaning",[1523,18497,18498],{},"Promise shape",[1530,18500,18501,18515,18529,18546],{},[1520,18502,18503,18507,18510],{},[1535,18504,18505],{},[17,18506,16171],{},[1535,18508,18509],{},"Server replied (any status)",[1535,18511,18512],{},[17,18513,18514],{},"resolve(new Response(...))",[1520,18516,18517,18521,18524],{},[1535,18518,18519],{},[17,18520,16192],{},[1535,18522,18523],{},"Network failure, CORS block, DNS",[1535,18525,18526],{},[17,18527,18528],{},"reject(new TypeError('Network error'))",[1520,18530,18531,18535,18541],{},[1535,18532,18533],{},[17,18534,16213],{},[1535,18536,18537,18540],{},[17,18538,18539],{},"xhr.abort()"," was called",[1535,18542,18543],{},[17,18544,18545],{},"reject(new DOMException('...', 'AbortError'))",[1520,18547,18548,18552,18558],{},[1535,18549,18550],{},[17,18551,16233],{},[1535,18553,18554,18557],{},[17,18555,18556],{},"xhr.timeout"," ms elapsed",[1535,18559,18560],{},[17,18561,18562],{},"reject(new DOMException('...', 'TimeoutError'))",[11,18564,18565,18567,18568,18571,18572,18575],{},[17,18566,12870],{}," uses ",[17,18569,18570],{},"TypeError"," for network errors and ",[17,18573,18574],{},"DOMException"," for abort. Matching those types means existing error handlers keep working when you swap transports.",[11,18577,18578,18579,18582,18583,18585,18586,522,18588,522,18590,18593],{},"A caller-set ",[17,18580,18581],{},"xhr.timeout = 30_000"," fires ",[17,18584,16233],{}," automatically without any extra code. That's one of the small wins of XHR over ",[17,18587,12870],{},[17,18589,4070],{},[17,18591,18592],{},"setTimeout",": the timeout machinery is built in.",[25,18595,18597],{"id":18596},"pitfalls-worth-naming","Pitfalls worth naming",[11,18599,18600],{},"A handful of traps that cost me time the first time I hit them.",[11,18602,18603,18609,18610,18613],{},[35,18604,18605,18606,18608],{},"Setting ",[17,18607,16934],{}," with FormData breaks multipart."," The browser's automatic ",[17,18611,18612],{},"multipart\u002Fform-data; boundary=..."," header is the only one the server can parse. Don't override it.",[11,18615,18616,18619,18620,18623,18624,18627],{},[35,18617,18618],{},"CORS progress is restricted."," For cross-origin requests, upload progress events only fire if the response includes the appropriate ",[17,18621,18622],{},"Access-Control-Allow-Origin"," header. If you're seeing ",[17,18625,18626],{},"loaded = 0"," forever on a cross-origin upload, it's almost certainly CORS and not your code.",[11,18629,18630,18638],{},[35,18631,18632,18635,18636,526],{},[17,18633,18634],{},"responseType"," must be set before ",[17,18637,17335],{}," Setting it after is a no-op. The browser needs to know how to buffer the response from the first byte.",[11,18640,18641,18646,18647,18649,18650,18653],{},[35,18642,18643,18644,526],{},"Gzip and ",[17,18645,16386],{}," Many CDNs strip ",[17,18648,16386],{}," when they apply gzip on the fly (because the compressed length is different from the decompressed length). Your client sees ",[17,18651,18652],{},"lengthComputable: false"," even though the server knew the size. There's no client-side fix. If you control the server, serve pre-compressed content or skip compression for assets where progress matters.",[11,18655,18656,8473,18659,18662,18663,18666,18667,18670],{},[35,18657,18658],{},"Don't hold the whole response in memory unnecessarily.",[17,18660,18661],{},"responseType: 'blob'"," lets the browser manage the buffer; ",[17,18664,18665],{},"responseType: 'arraybuffer'"," forces it into JS-heap memory. For anything above a few MB, ",[17,18668,18669],{},"blob"," is the right default.",[25,18672,18674],{"id":18673},"what-id-reach-for-first","What I'd reach for first",[11,18676,18677],{},"For any project that needs real progress UIs, the shape I'd build every time:",[8453,18679,18680,18688,18697,18705,18718],{},[3669,18681,18682,18687],{},[35,18683,18684,18685,526],{},"Default transport is ",[17,18686,12870],{}," It's the modern primitive; it's faster; it hydrates into SSR-friendly patterns cleanly.",[3669,18689,18690,18693,18694,18696],{},[35,18691,18692],{},"Opt into XHR only when the caller asks for progress."," A single ",[17,18695,12885],{}," option is enough to swap transports for that specific call.",[3669,18698,18699,18704],{},[35,18700,18701,18702],{},"The XHR path returns a ",[17,18703,14013],{},", so everything downstream keeps working: interceptors, retry, error mapping, JSON parsing.",[3669,18706,18707,18714,18715,18717],{},[35,18708,18709,18710,18713],{},"Normalize ",[17,18711,18712],{},"{ loaded, total, ratio }"," at the transport layer."," Hand ",[17,18716,171],{}," upstream when the total is unknown. Let the UI decide how to render that state.",[3669,18719,18720,18726,18727,18729,18730,18732],{},[35,18721,18722,18723,18725],{},"Bridge ",[17,18724,14572],{}," once"," in the wrapper, not in every caller. XHR's ",[17,18728,18422],{}," and fetch's ",[17,18731,4070],{}," exist in the same logical slot; the wrapper translates.",[11,18734,18735],{},"That's the whole picture. XHR isn't the cool API anymore; it's the one that still works for a specific thing fetch can't do, and the small amount of glue code above is what keeps it from contaminating the rest of the codebase.",[218,18737,18738],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":44,"searchDepth":81,"depth":81,"links":18740},[18741,18743,18751,18753,18754,18756,18757,18758,18760,18761,18762],{"id":13995,"depth":81,"text":18742},"Why native fetch is the default primitive",{"id":14139,"depth":81,"text":14140,"children":18744},[18745,18746,18748,18749,18750],{"id":14161,"depth":87,"text":14162},{"id":14568,"depth":87,"text":18747},"Layer 2: timeouts via AbortSignal",{"id":14827,"depth":87,"text":14828},{"id":15532,"depth":87,"text":15533},{"id":15749,"depth":87,"text":15750},{"id":15893,"depth":81,"text":18752},"Why fetch can't show upload progress",{"id":16096,"depth":81,"text":16097},{"id":16300,"depth":81,"text":18755},"The ProgressEvent shape, and lengthComputable",{"id":16524,"depth":81,"text":16525},{"id":16969,"depth":81,"text":16970},{"id":17356,"depth":81,"text":18759},"Wrapping XHR to look like fetch",{"id":18467,"depth":81,"text":18468},{"id":18596,"depth":81,"text":18597},{"id":18673,"depth":81,"text":18674},"2025-09-22","Why native fetch is the right default, how to build a layered wrapper (base URL, timeouts, interceptors, retry), and when you still need XHR.","لماذا يبقى fetch الأصلي الخيار الافتراضي الصحيح، وكيف تبني حوله غلافاً متعدّد الطبقات (رابط أساسي، ومهلات، واعتراضات، وإعادة محاولات)، ومتى تظل بحاجة إلى XHR.",[18767,13958,18768,13902,18769,4070,14572,18770,13901,18771,18772,1845],"fetch API","XHR","download progress","custom HTTP client","retry","exponential backoff",{},"\u002Fwrites\u002Fupload-and-download-progress-in-the-browser",{"title":13916,"description":18764},"writes\u002Fupload-and-download-progress-in-the-browser","غلاف مخصَّص لـ fetch، وXHR لتقدُّم الرفع","vCepZSovWCiH7mQTZEqzp9WPeIcjAJH2ksJYbQ6skaY",{"id":18780,"title":18781,"body":18782,"date":19000,"description":19001,"description_ar":19002,"draft":1835,"extension":1836,"image":1837,"keywords":19003,"lang":1848,"meta":19009,"navigation":323,"path":19010,"seo":19011,"stem":19012,"title_ar":19013,"updatedAt":1837,"__hash__":19014},"writes\u002Fwrites\u002Fvalidating-image-urls-without-complex-error-handling.md","Validating image URLs without complex error handling",{"type":8,"value":18783,"toc":18995},[18784,18790,18797,18801,18804,18833,18837,18853,18878,18881,18885,18888,18959,18981,18984,18992],[11,18785,18786,18787,18789],{},"If you have ever rendered a list of images from untrusted data, you know the pattern. Most URLs are fine, a few are dead, and the broken-image icon ruins the layout. You can wrap everything in ",[17,18788,14819],{},", or you can ask the browser directly: can you load this?",[11,18791,18792,18793,18796],{},"That is what ",[17,18794,18795],{},"checkUrl"," does. It returns a Promise that resolves when the image loads and rejects when it does not. No fetch, no CORS handling, and no manual HEAD requests.",[25,18798,18800],{"id":18799},"the-idea","The idea",[11,18802,18803],{},"A Promise that resolves or rejects based on whether the image loads:",[39,18805,18807],{"className":6454,"code":18806,"language":6456,"meta":44,"style":44},"\u002F\u002F success\nconst promise = new Promise\u003Cvoid>((resolve, reject) => { resolve() })\n\n\u002F\u002F failure\nconst promise = new Promise\u003Cvoid>((resolve, reject) => { reject() })\n",[17,18808,18809,18814,18819,18823,18828],{"__ignoreMap":44},[48,18810,18811],{"class":50,"line":51},[48,18812,18813],{},"\u002F\u002F success\n",[48,18815,18816],{"class":50,"line":81},[48,18817,18818],{},"const promise = new Promise\u003Cvoid>((resolve, reject) => { resolve() })\n",[48,18820,18821],{"class":50,"line":87},[48,18822,324],{"emptyLinePlaceholder":323},[48,18824,18825],{"class":50,"line":93},[48,18826,18827],{},"\u002F\u002F failure\n",[48,18829,18830],{"class":50,"line":362},[48,18831,18832],{},"const promise = new Promise\u003Cvoid>((resolve, reject) => { reject() })\n",[25,18834,18836],{"id":18835},"using-the-built-in-image-object","Using the built-in Image object",[11,18838,18839,18840,18843,18844,1294,18846,18848,18849,18852],{},"Inside the callback, create an ",[17,18841,18842],{},"Image",", wire up ",[17,18845,16171],{},[17,18847,16192],{},", and then set ",[17,18850,18851],{},"src"," to start the request.",[39,18854,18856],{"className":6454,"code":18855,"language":6456,"meta":44,"style":44},"const img = new Image();\nimg.onload = () => resolve();\nimg.onerror = () => reject();\nimg.src = url;\n",[17,18857,18858,18863,18868,18873],{"__ignoreMap":44},[48,18859,18860],{"class":50,"line":51},[48,18861,18862],{},"const img = new Image();\n",[48,18864,18865],{"class":50,"line":81},[48,18866,18867],{},"img.onload = () => resolve();\n",[48,18869,18870],{"class":50,"line":87},[48,18871,18872],{},"img.onerror = () => reject();\n",[48,18874,18875],{"class":50,"line":93},[48,18876,18877],{},"img.src = url;\n",[11,18879,18880],{},"That is the entire mechanism. The browser does the work, and the Promise is a thin wrapper around two events.",[25,18882,18884],{"id":18883},"using-it-with-a-fallback","Using it with a fallback",[11,18886,18887],{},"The most common use case is swapping to a fallback when the original URL is broken.",[39,18889,18891],{"className":6454,"code":18890,"language":6456,"meta":44,"style":44},"const fallbackImage = 'https:\u002F\u002Fexample.com\u002Ffallback.png';\nlet url: string = anonymousOBJECT.image;\n\nconst checkImage = async () => {\n  try {\n    await checkUrl(url);\n    \u002F\u002F valid, keep the original\n  } catch {\n    \u002F\u002F invalid, swap in the fallback\n    url = fallbackImage;\n  }\n};\n\ncheckImage();\n",[17,18892,18893,18898,18903,18907,18912,18917,18922,18927,18932,18937,18942,18946,18950,18954],{"__ignoreMap":44},[48,18894,18895],{"class":50,"line":51},[48,18896,18897],{},"const fallbackImage = 'https:\u002F\u002Fexample.com\u002Ffallback.png';\n",[48,18899,18900],{"class":50,"line":81},[48,18901,18902],{},"let url: string = anonymousOBJECT.image;\n",[48,18904,18905],{"class":50,"line":87},[48,18906,324],{"emptyLinePlaceholder":323},[48,18908,18909],{"class":50,"line":93},[48,18910,18911],{},"const checkImage = async () => {\n",[48,18913,18914],{"class":50,"line":362},[48,18915,18916],{},"  try {\n",[48,18918,18919],{"class":50,"line":373},[48,18920,18921],{},"    await checkUrl(url);\n",[48,18923,18924],{"class":50,"line":384},[48,18925,18926],{},"    \u002F\u002F valid, keep the original\n",[48,18928,18929],{"class":50,"line":661},[48,18930,18931],{},"  } catch {\n",[48,18933,18934],{"class":50,"line":690},[48,18935,18936],{},"    \u002F\u002F invalid, swap in the fallback\n",[48,18938,18939],{"class":50,"line":695},[48,18940,18941],{},"    url = fallbackImage;\n",[48,18943,18944],{"class":50,"line":722},[48,18945,2590],{},[48,18947,18948],{"class":50,"line":743},[48,18949,5737],{},[48,18951,18952],{"class":50,"line":767},[48,18953,324],{"emptyLinePlaceholder":323},[48,18955,18956],{"class":50,"line":778},[48,18957,18958],{},"checkImage();\n",[39,18960,18962],{"className":985,"code":18961,"language":987,"meta":44,"style":44},"\u003Cimg :src=\"url\" \u002F>\n",[17,18963,18964],{"__ignoreMap":44},[48,18965,18966,18968,18971,18974,18976,18979],{"class":50,"line":51},[48,18967,55],{"class":54},[48,18969,18970],{"class":58},"img",[48,18972,18973],{"class":261}," :src",[48,18975,66],{"class":54},[48,18977,18978],{"class":69},"\"url\"",[48,18980,884],{"class":54},[11,18982,18983],{},"No custom error handling in the template, no flicker of a broken image, and a resolved URL by the time it hits the DOM.",[11,18985,18986,18987,526],{},"Originally published on ",[9485,18988,18991],{"href":18989,"rel":18990},"https:\u002F\u002Fwww.linkedin.com\u002Fpulse\u002Fwithout-having-write-complex-error-handling-code-image-ali-abdelbaqy\u002F",[13864],"LinkedIn",[218,18993,18994],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":44,"searchDepth":81,"depth":81,"links":18996},[18997,18998,18999],{"id":18799,"depth":81,"text":18800},{"id":18835,"depth":81,"text":18836},{"id":18883,"depth":81,"text":18884},"2023-07-11","A small async helper that uses the browser's Image object to tell you whether a URL actually resolves to a loadable image, without a fetch and without CORS concerns.","دالة مساعدة صغيرة غير متزامنة تستخدم كائن Image في المتصفح لتخبرك ما إذا كان الرابط ينتهي فعلاً إلى صورة قابلة للتحميل، بلا fetch وبلا صداع CORS.",[19004,19005,3746,13608,19006,19007,19008,1845],"JavaScript","image validation","fallback image","Image object","browser API",{},"\u002Fwrites\u002Fvalidating-image-urls-without-complex-error-handling",{"title":18781,"description":19001},"writes\u002Fvalidating-image-urls-without-complex-error-handling","التحقق من روابط الصور دون معالجة أخطاء معقّدة","h_HCm_3IJp_NFDHbwYyYhTOhK6GBScg9xWtzFpgoyD0",1793901268435]