[{"data":1,"prerenderedAt":16207},["ShallowReactive",2],{"blog-list":3},[4,4055,5959,9370,14280,15968],{"id":5,"title":6,"body":7,"date":4037,"description":4038,"draft":4039,"extension":4040,"image":4041,"keywords":4042,"lang":4041,"meta":4050,"navigation":116,"path":4051,"seo":4052,"stem":4053,"updatedAt":4037,"__hash__":4054},"blog\u002Fblog\u002Fanatomy-of-adatatable.md","Anatomy of ADataTable — a typed generic Vue data table",{"type":8,"value":9,"toc":4014},"minimark",[10,19,26,29,37,40,171,182,185,493,496,508,526,541,548,561,580,586,744,776,782,864,890,911,917,926,943,1154,1157,1204,1207,1212,1320,1326,1348,1356,1378,1385,1394,1605,1610,1764,1778,1795,1799,1811,1814,1883,1886,2159,2162,2211,2228,2235,2246,2335,2338,2440,2443,2465,2472,2479,2551,2565,2573,2580,2700,2706,2722,2736,2749,2765,2768,2828,2831,2835,2838,2877,2884,2887,3012,3019,3022,3278,3285,3288,3299,3317,3419,3432,3442,3447,3566,3569,3576,3663,3670,3674,3677,3683,3739,3744,3747,3818,3832,3839,3913,3926,3930,3933,3998,4010],[11,12,13,14,18],"p",{},"I've written the same data table three times. Once as a ball of JSX, once as a class-based configuration object called ",[15,16,17],"code",{},"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,20,21,22,25],{},"This post is a tour of the current shape. It lives in my UI package as ",[15,23,24],{},"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,27,28],{},"I'll walk the files in roughly the order the request flows through them, pausing on the bits worth explaining.",[30,31,33,34,36],"h2",{"id":32},"from-icolumn-class-to-typed-interface","From ",[15,35,17],{}," class to typed interface",[11,38,39],{},"Two years ago I had a class:",[41,42,47],"pre",{"className":43,"code":44,"language":45,"meta":46,"style":46},"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","",[15,48,49,57,63,69,75,81,87,93,99,105,111,118,124,130,136,142,148,154,160,165],{"__ignoreMap":46},[50,51,54],"span",{"class":52,"line":53},"line",1,[50,55,56],{},"export class IColumn {\n",[50,58,60],{"class":52,"line":59},2,[50,61,62],{},"  constructor(options) {\n",[50,64,66],{"class":52,"line":65},3,[50,67,68],{},"    const { title, rowKey, type = 'default', sortable = false, \u002F* ... *\u002F } = options;\n",[50,70,72],{"class":52,"line":71},4,[50,73,74],{},"    this.title = title;\n",[50,76,78],{"class":52,"line":77},5,[50,79,80],{},"    this.rowKey = rowKey;\n",[50,82,84],{"class":52,"line":83},6,[50,85,86],{},"    this.sorter = sorter || ((a, b) => a[this.rowKey] > b[this.rowKey]);\n",[50,88,90],{"class":52,"line":89},7,[50,91,92],{},"    this.headerComponent = this.processComponent(headerComponent);\n",[50,94,96],{"class":52,"line":95},8,[50,97,98],{},"    this.rowComponent = this.processComponent(rowComponent);\n",[50,100,102],{"class":52,"line":101},9,[50,103,104],{},"    \u002F\u002F ...\n",[50,106,108],{"class":52,"line":107},10,[50,109,110],{},"  }\n",[50,112,114],{"class":52,"line":113},11,[50,115,117],{"emptyLinePlaceholder":116},true,"\n",[50,119,121],{"class":52,"line":120},12,[50,122,123],{},"  processComponent(component) {\n",[50,125,127],{"class":52,"line":126},13,[50,128,129],{},"    if (!component) return undefined;\n",[50,131,133],{"class":52,"line":132},14,[50,134,135],{},"    return {\n",[50,137,139],{"class":52,"line":138},15,[50,140,141],{},"      is: component.is || '',\n",[50,143,145],{"class":52,"line":144},16,[50,146,147],{},"      props: typeof component.props === 'function' ? component.props : () => component.props,\n",[50,149,151],{"class":52,"line":150},17,[50,152,153],{},"      events: typeof component.events === 'function' ? component.events : () => component.events,\n",[50,155,157],{"class":52,"line":156},18,[50,158,159],{},"    };\n",[50,161,163],{"class":52,"line":162},19,[50,164,110],{},[50,166,168],{"class":52,"line":167},20,[50,169,170],{},"}\n",[11,172,173,174,177,178,181],{},"The class normalized props and events — if you passed a static object, it got wrapped in ",[15,175,176],{},"() => 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 ",[15,179,180],{},"row.user.name",".",[11,183,184],{},"The current shape is a plain typed interface:",[41,186,190],{"className":187,"code":188,"language":189,"meta":46,"style":46},"language-typescript shiki shiki-themes github-light github-dark","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","typescript",[15,191,192,222,247,266,302,326,346,350,380,396,400,411,448,460,489],{"__ignoreMap":46},[50,193,194,198,201,205,209,212,215,219],{"class":52,"line":53},[50,195,197],{"class":196},"szBVR","export",[50,199,200],{"class":196}," interface",[50,202,204],{"class":203},"sScJk"," IDataTableColumn",[50,206,208],{"class":207},"sVt8B","\u003C",[50,210,211],{"class":203},"T",[50,213,214],{"class":196}," =",[50,216,218],{"class":217},"sj4cs"," unknown",[50,220,221],{"class":207},"> {\n",[50,223,224,228,231,234,237,240,242,244],{"class":52,"line":59},[50,225,227],{"class":226},"s4XuR","  title",[50,229,230],{"class":196},":",[50,232,233],{"class":217}," string",[50,235,236],{"class":196}," |",[50,238,239],{"class":203}," IDataTableRowFunction",[50,241,208],{"class":207},[50,243,211],{"class":203},[50,245,246],{"class":207},">;\n",[50,248,249,252,254,256,258,260,262,264],{"class":52,"line":65},[50,250,251],{"class":226},"  key",[50,253,230],{"class":196},[50,255,233],{"class":217},[50,257,236],{"class":196},[50,259,239],{"class":203},[50,261,208],{"class":207},[50,263,211],{"class":203},[50,265,246],{"class":207},[50,267,268,271,274,277,279,282,285,288,291,294,296,298,300],{"class":52,"line":71},[50,269,270],{"class":226},"  props",[50,272,273],{"class":196},"?:",[50,275,276],{"class":203}," Record",[50,278,208],{"class":207},[50,280,281],{"class":217},"string",[50,283,284],{"class":207},", ",[50,286,287],{"class":217},"unknown",[50,289,290],{"class":207},"> ",[50,292,293],{"class":196},"|",[50,295,239],{"class":203},[50,297,208],{"class":207},[50,299,211],{"class":203},[50,301,246],{"class":207},[50,303,304,307,309,313,315,318,320,323],{"class":52,"line":77},[50,305,306],{"class":226},"  type",[50,308,273],{"class":196},[50,310,312],{"class":311},"sZZnC"," 'default'",[50,314,236],{"class":196},[50,316,317],{"class":311}," 'actions'",[50,319,236],{"class":196},[50,321,322],{"class":311}," 'selection'",[50,324,325],{"class":207},";\n",[50,327,328,331,333,336,338,340,342,344],{"class":52,"line":83},[50,329,330],{"class":226},"  disabled",[50,332,273],{"class":196},[50,334,335],{"class":217}," boolean",[50,337,236],{"class":196},[50,339,239],{"class":203},[50,341,208],{"class":207},[50,343,211],{"class":203},[50,345,246],{"class":207},[50,347,348],{"class":52,"line":89},[50,349,117],{"emptyLinePlaceholder":116},[50,351,352,355,357,360,363,366,368,371,373,375,378],{"class":52,"line":95},[50,353,354],{"class":203},"  headRender",[50,356,273],{"class":196},[50,358,359],{"class":207}," () ",[50,361,362],{"class":196},"=>",[50,364,365],{"class":203}," Array",[50,367,208],{"class":207},[50,369,370],{"class":203},"IDataTableVNodeChild",[50,372,290],{"class":207},[50,374,293],{"class":196},[50,376,377],{"class":203}," IDataTableVNodeChild",[50,379,325],{"class":207},[50,381,382,385,387,390,392,394],{"class":52,"line":101},[50,383,384],{"class":226},"  bodyRender",[50,386,273],{"class":196},[50,388,389],{"class":203}," IDataTableRenderFunction",[50,391,208],{"class":207},[50,393,211],{"class":203},[50,395,246],{"class":207},[50,397,398],{"class":52,"line":107},[50,399,117],{"emptyLinePlaceholder":116},[50,401,402,405,407,409],{"class":52,"line":113},[50,403,404],{"class":226},"  sortable",[50,406,273],{"class":196},[50,408,335],{"class":217},[50,410,325],{"class":207},[50,412,413,416,418,421,424,426,429,431,434,436,438,441,443,446],{"class":52,"line":120},[50,414,415],{"class":203},"  sorter",[50,417,273],{"class":196},[50,419,420],{"class":207}," (",[50,422,423],{"class":226},"row1",[50,425,230],{"class":196},[50,427,428],{"class":203}," T",[50,430,284],{"class":207},[50,432,433],{"class":226},"row2",[50,435,230],{"class":196},[50,437,428],{"class":203},[50,439,440],{"class":207},") ",[50,442,362],{"class":196},[50,444,445],{"class":217}," number",[50,447,325],{"class":207},[50,449,450,453,455,458],{"class":52,"line":126},[50,451,452],{"class":226},"  sortValue",[50,454,273],{"class":196},[50,456,457],{"class":203}," ISortValue",[50,459,325],{"class":207},[50,461,462,465,467,469,471,473,475,477,479,481,483,485,487],{"class":52,"line":132},[50,463,464],{"class":226},"  extras",[50,466,273],{"class":196},[50,468,276],{"class":203},[50,470,208],{"class":207},[50,472,281],{"class":217},[50,474,284],{"class":207},[50,476,287],{"class":217},[50,478,290],{"class":207},[50,480,293],{"class":196},[50,482,239],{"class":203},[50,484,208],{"class":207},[50,486,211],{"class":203},[50,488,246],{"class":207},[50,490,491],{"class":52,"line":138},[50,492,170],{"class":207},[11,494,495],{},"Three things changed, and all three mattered.",[11,497,498,499,502,503,507],{},"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 (",[15,500,501],{},"handleProps",", which we'll hit in a minute) — the cost is one branch per cell render instead of one branch per column ",[504,505,506],"em",{},"definition",", and the benefit is the column stays a value, not a runtime object.",[11,509,510,511,513,514,517,518,521,522,525],{},"Second, the column is generic over ",[15,512,211],{},". ",[15,515,516],{},"IDataTableColumn\u003CUser>"," means ",[15,519,520],{},"key"," typed as ",[15,523,524],{},"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,527,528,529,532,533,536,537,540],{},"Third, render responsibilities split cleanly. ",[15,530,531],{},"headRender"," is for the header cell only, ",[15,534,535],{},"bodyRender"," for the body cell. The class version had a single ",[15,538,539],{},"rowComponent"," that did both and branched internally — which meant \"custom header, default body\" required an undocumented combination of nullish fields.",[11,542,543,544,547],{},"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 ",[504,545,546],{},"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.",[30,549,551,552,284,555,284,558],{"id":550},"the-render-trio-athead-atbody-atcell","The render trio: ",[15,553,554],{},"ATHead",[15,556,557],{},"ATBody",[15,559,560],{},"ATCell",[11,562,563,564,567,568,571,572,575,576,579],{},"The table component does not render rows directly. It renders a ",[15,565,566],{},"\u003Cthead>",", a ",[15,569,570],{},"\u003Ctbody>",", and those render ",[15,573,574],{},"\u003Ctr>"," elements that render ",[15,577,578],{},"\u003CATCell>"," components. The split is tight enough that each file has one job.",[11,581,582,585],{},[15,583,584],{},"ATHead.vue"," is the simplest:",[41,587,591],{"className":588,"code":589,"language":590,"meta":46,"style":46},"language-vue shiki shiki-themes github-light github-dark","\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","vue",[15,592,593,604,614,624,632,643,653,663,673,683,693,702,712,717,726,735],{"__ignoreMap":46},[50,594,595,597,601],{"class":52,"line":53},[50,596,208],{"class":207},[50,598,600],{"class":599},"s9eBZ","template",[50,602,603],{"class":207},">\n",[50,605,606,609,612],{"class":52,"line":59},[50,607,608],{"class":207},"  \u003C",[50,610,611],{"class":599},"thead",[50,613,603],{"class":207},[50,615,616,619,622],{"class":52,"line":65},[50,617,618],{"class":207},"    \u003C",[50,620,621],{"class":599},"tr",[50,623,603],{"class":207},[50,625,626,629],{"class":52,"line":71},[50,627,628],{"class":207},"      \u003C",[50,630,631],{"class":599},"ATCell\n",[50,633,634,637,640],{"class":52,"line":77},[50,635,636],{"class":203},"        v-for",[50,638,639],{"class":207},"=",[50,641,642],{"class":311},"\"(column, collIndex) in props.columns\"\n",[50,644,645,648,650],{"class":52,"line":83},[50,646,647],{"class":203},"        :key",[50,649,639],{"class":207},[50,651,652],{"class":311},"\"collIndex\"\n",[50,654,655,658,660],{"class":52,"line":89},[50,656,657],{"class":203},"        :binder",[50,659,639],{"class":207},[50,661,662],{"class":311},"\"column.props\"\n",[50,664,665,668,670],{"class":52,"line":95},[50,666,667],{"class":203},"        :cell",[50,669,639],{"class":207},[50,671,672],{"class":311},"\"column\"\n",[50,674,675,678,680],{"class":52,"line":101},[50,676,677],{"class":203},"        :head",[50,679,639],{"class":207},[50,681,682],{"class":311},"\"true\"\n",[50,684,685,688,690],{"class":52,"line":107},[50,686,687],{"class":203},"        :record",[50,689,639],{"class":207},[50,691,692],{"class":311},"\"{} as S\"\n",[50,694,695,698,700],{"class":52,"line":113},[50,696,697],{"class":203},"        :row-index",[50,699,639],{"class":207},[50,701,652],{"class":311},[50,703,704,707,709],{"class":52,"line":120},[50,705,706],{"class":203},"        @update-sorter",[50,708,639],{"class":207},[50,710,711],{"class":311},"\"emit('updateSorter', collIndex)\"\n",[50,713,714],{"class":52,"line":126},[50,715,716],{"class":207},"      \u002F>\n",[50,718,719,722,724],{"class":52,"line":132},[50,720,721],{"class":207},"    \u003C\u002F",[50,723,621],{"class":599},[50,725,603],{"class":207},[50,727,728,731,733],{"class":52,"line":138},[50,729,730],{"class":207},"  \u003C\u002F",[50,732,611],{"class":599},[50,734,603],{"class":207},[50,736,737,740,742],{"class":52,"line":144},[50,738,739],{"class":207},"\u003C\u002F",[50,741,600],{"class":599},[50,743,603],{"class":207},[11,745,746,747,749,750,752,753,756,757,760,761,764,765,767,768,771,772,775],{},"One ",[15,748,574],{},". One ",[15,751,578],{}," per column, with ",[15,754,755],{},"head={true}",". The ",[15,758,759],{},"record"," is a synthetic empty object cast to ",[15,762,763],{},"S"," — the header doesn't have a row, but ",[15,766,560],{}," is generic over it, so something has to fill the slot. Casting ",[15,769,770],{},"{} as S"," is the least-bad option; giving it an actual ",[15,773,774],{},"undefined"," would force every cell render to null-check.",[11,777,778,781],{},[15,779,780],{},"ATBody.vue"," is the same shape, one level up:",[41,783,785],{"className":588,"code":784,"language":590,"meta":46,"style":46},"\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",[15,786,787,796,801,806,811,816,821,826,831,836,841,846,851,856],{"__ignoreMap":46},[50,788,789,791,794],{"class":52,"line":53},[50,790,208],{"class":207},[50,792,793],{"class":599},"tbody",[50,795,603],{"class":207},[50,797,798],{"class":52,"line":59},[50,799,800],{"class":207},"  \u003Ctr v-for=\"(dataItem, $index) in props.items\" :key=\"$index + '_dataItem'\">\n",[50,802,803],{"class":52,"line":65},[50,804,805],{"class":207},"    \u003CATCell\n",[50,807,808],{"class":52,"line":71},[50,809,810],{"class":207},"      v-for=\"(column, collIndex) in props.columns\"\n",[50,812,813],{"class":52,"line":77},[50,814,815],{"class":207},"      :key=\"collIndex + '_dataItem_' + $index\"\n",[50,817,818],{"class":52,"line":83},[50,819,820],{"class":207},"      :record=\"dataItem\"\n",[50,822,823],{"class":52,"line":89},[50,824,825],{"class":207},"      :head=\"false\"\n",[50,827,828],{"class":52,"line":95},[50,829,830],{"class":207},"      :row-index=\"$index\"\n",[50,832,833],{"class":52,"line":101},[50,834,835],{"class":207},"      :cell=\"column\"\n",[50,837,838],{"class":52,"line":107},[50,839,840],{"class":207},"      :binder=\"column.props\"\n",[50,842,843],{"class":52,"line":113},[50,844,845],{"class":207},"    \u002F>\n",[50,847,848],{"class":52,"line":120},[50,849,850],{"class":207},"  \u003C\u002Ftr>\n",[50,852,853],{"class":52,"line":126},[50,854,855],{"class":207},"  \u003Cslot \u002F>\n",[50,857,858,860,862],{"class":52,"line":132},[50,859,739],{"class":207},[50,861,793],{"class":599},[50,863,603],{"class":207},[11,865,866,867,284,870,873,874,877,878,881,882,885,886,889],{},"The composed keys (",[15,868,869],{},"$index + '_dataItem'",[15,871,872],{},"collIndex + '_dataItem_' + $index",") aren't paranoia — Vue's reconciler only cares about keys being unique ",[504,875,876],{},"within their parent list",", so the ",[15,879,880],{},"$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 \"",[15,883,884],{},"3_dataItem","\" instead of just \"",[15,887,888],{},"3","\" saves me a second. That's the only reason for the suffix.",[11,891,892,893,896,897,899,900,903,904,907,908,910],{},"The ",[15,894,895],{},"\u003Cslot \u002F>"," inside ",[15,898,570],{}," is where the no-data row lands — the outer component pipes ",[15,901,902],{},"\u003Ctr>\u003Ctd>...\u003C\u002Ftd>\u003C\u002Ftr>"," through it. Keeping the empty-state as a slotted row rather than a separate element means ",[15,905,906],{},"colspan"," math works against the same ",[15,909,574],{}," grid, and the border-collapsing styles don't break.",[11,912,913,914,181],{},"The real work happens in ",[15,915,916],{},"ATCell.vue",[30,918,920,922,923],{"id":919},"atcell-one-component-two-shapes-built-via-h",[15,921,560],{},": one component, two shapes, built via ",[15,924,925],{},"h()",[11,927,928,930,931,934,935,938,939,942],{},[15,929,560],{}," renders either a ",[15,932,933],{},"\u003Cth>"," or a ",[15,936,937],{},"\u003Ctd>",", depending on ",[15,940,941],{},"head",". Rather than writing two templates, it builds the vnode dynamically:",[41,944,946],{"className":187,"code":945,"language":189,"meta":46,"style":46},"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",[15,947,948,969,980,1000,1005,1015,1025,1034,1048,1061,1066,1086,1091,1099,1119,1130,1139,1144,1149],{"__ignoreMap":46},[50,949,950,953,956,958,961,964,966],{"class":52,"line":53},[50,951,952],{"class":196},"const",[50,954,955],{"class":217}," Cell",[50,957,214],{"class":196},[50,959,960],{"class":203}," computed",[50,962,963],{"class":207},"(() ",[50,965,362],{"class":196},[50,967,968],{"class":207}," {\n",[50,970,971,974,977],{"class":52,"line":59},[50,972,973],{"class":196},"  return",[50,975,976],{"class":203}," h",[50,978,979],{"class":207},"(\n",[50,981,982,985,988,991,994,997],{"class":52,"line":65},[50,983,984],{"class":207},"    props.head ",[50,986,987],{"class":196},"?",[50,989,990],{"class":311}," 'th'",[50,992,993],{"class":196}," :",[50,995,996],{"class":311}," 'td'",[50,998,999],{"class":207},",\n",[50,1001,1002],{"class":52,"line":71},[50,1003,1004],{"class":207},"    {\n",[50,1006,1007,1010,1013],{"class":52,"line":77},[50,1008,1009],{"class":207},"      class: ",[50,1011,1012],{"class":311},"'p-[16px] text-md gap-2 relative'",[50,1014,999],{"class":207},[50,1016,1017,1020,1023],{"class":52,"line":83},[50,1018,1019],{"class":207},"      colspan: ",[50,1021,1022],{"class":311},"'1'",[50,1024,999],{"class":207},[50,1026,1027,1030,1032],{"class":52,"line":89},[50,1028,1029],{"class":207},"      rowspan: ",[50,1031,1022],{"class":311},[50,1033,999],{"class":207},[50,1035,1036,1039,1041,1043,1045],{"class":52,"line":95},[50,1037,1038],{"class":196},"      ...",[50,1040,501],{"class":203},[50,1042,208],{"class":207},[50,1044,211],{"class":203},[50,1046,1047],{"class":207},">(props.cell.props, props.record),\n",[50,1049,1050,1052,1054,1056,1058],{"class":52,"line":101},[50,1051,1038],{"class":196},[50,1053,501],{"class":203},[50,1055,208],{"class":207},[50,1057,211],{"class":203},[50,1059,1060],{"class":207},">(props.binder, props.record),\n",[50,1062,1063],{"class":52,"line":107},[50,1064,1065],{"class":207},"    },\n",[50,1067,1068,1071,1074,1077,1080,1083],{"class":52,"line":113},[50,1069,1070],{"class":203},"    h",[50,1072,1073],{"class":207},"(",[50,1075,1076],{"class":311},"'div'",[50,1078,1079],{"class":207},", { class: ",[50,1081,1082],{"class":311},"'flex items-center gap-2 w-full'",[50,1084,1085],{"class":207}," },\n",[50,1087,1088],{"class":52,"line":120},[50,1089,1090],{"class":207},"      props.head\n",[50,1092,1093,1096],{"class":52,"line":126},[50,1094,1095],{"class":196},"        ?",[50,1097,1098],{"class":207}," props.cell.sortable\n",[50,1100,1101,1104,1107,1110,1113,1116],{"class":52,"line":132},[50,1102,1103],{"class":196},"          ?",[50,1105,1106],{"class":207}," [",[50,1108,1109],{"class":203},"children",[50,1111,1112],{"class":207},"(props.cell, props.record), ",[50,1114,1115],{"class":203},"sortButton",[50,1117,1118],{"class":207},"(props.cell.sortValue?.applied)]\n",[50,1120,1121,1124,1127],{"class":52,"line":138},[50,1122,1123],{"class":196},"          :",[50,1125,1126],{"class":203}," children",[50,1128,1129],{"class":207},"(props.cell, props.record)\n",[50,1131,1132,1135,1137],{"class":52,"line":144},[50,1133,1134],{"class":196},"        :",[50,1136,1126],{"class":203},[50,1138,1129],{"class":207},[50,1140,1141],{"class":52,"line":150},[50,1142,1143],{"class":207},"    )\n",[50,1145,1146],{"class":52,"line":156},[50,1147,1148],{"class":207},"  );\n",[50,1150,1151],{"class":52,"line":162},[50,1152,1153],{"class":207},"});\n",[11,1155,1156],{},"Then the template just mounts the computed vnode:",[41,1158,1160],{"className":588,"code":1159,"language":590,"meta":46,"style":46},"\u003Ctemplate>\n  \u003Ccomponent :is=\"Cell\" :key=\"props.cell.sortValue?.applied || 'ATCell'\" \u002F>\n\u003C\u002Ftemplate>\n",[15,1161,1162,1170,1196],{"__ignoreMap":46},[50,1163,1164,1166,1168],{"class":52,"line":53},[50,1165,208],{"class":207},[50,1167,600],{"class":599},[50,1169,603],{"class":207},[50,1171,1172,1174,1177,1180,1182,1185,1188,1190,1193],{"class":52,"line":59},[50,1173,608],{"class":207},[50,1175,1176],{"class":599},"component",[50,1178,1179],{"class":203}," :is",[50,1181,639],{"class":207},[50,1183,1184],{"class":311},"\"Cell\"",[50,1186,1187],{"class":203}," :key",[50,1189,639],{"class":207},[50,1191,1192],{"class":311},"\"props.cell.sortValue?.applied || 'ATCell'\"",[50,1194,1195],{"class":207}," \u002F>\n",[50,1197,1198,1200,1202],{"class":52,"line":65},[50,1199,739],{"class":207},[50,1201,600],{"class":599},[50,1203,603],{"class":207},[11,1205,1206],{},"Two things are doing quiet work here.",[11,1208,1209,1211],{},[15,1210,501],{}," accepts either a plain object or a function of the row and always returns an object:",[41,1213,1215],{"className":187,"code":1214,"language":189,"meta":46,"style":46},"const handleProps = \u003CT,>(val: IDataTableColumn\u003CT>['props'], record: T) => {\n  if (!val) return {};\n  return typeof val === 'function' ? val(record) : val;\n};\n",[15,1216,1217,1266,1285,1315],{"__ignoreMap":46},[50,1218,1219,1221,1224,1226,1229,1231,1234,1237,1239,1241,1243,1245,1248,1251,1254,1256,1258,1260,1262,1264],{"class":52,"line":53},[50,1220,952],{"class":196},[50,1222,1223],{"class":203}," handleProps",[50,1225,214],{"class":196},[50,1227,1228],{"class":207}," \u003C",[50,1230,211],{"class":203},[50,1232,1233],{"class":207},",>(",[50,1235,1236],{"class":226},"val",[50,1238,230],{"class":196},[50,1240,204],{"class":203},[50,1242,208],{"class":207},[50,1244,211],{"class":203},[50,1246,1247],{"class":207},">[",[50,1249,1250],{"class":311},"'props'",[50,1252,1253],{"class":207},"], ",[50,1255,759],{"class":226},[50,1257,230],{"class":196},[50,1259,428],{"class":203},[50,1261,440],{"class":207},[50,1263,362],{"class":196},[50,1265,968],{"class":207},[50,1267,1268,1271,1273,1276,1279,1282],{"class":52,"line":59},[50,1269,1270],{"class":196},"  if",[50,1272,420],{"class":207},[50,1274,1275],{"class":196},"!",[50,1277,1278],{"class":207},"val) ",[50,1280,1281],{"class":196},"return",[50,1283,1284],{"class":207}," {};\n",[50,1286,1287,1289,1292,1295,1298,1301,1304,1307,1310,1312],{"class":52,"line":65},[50,1288,973],{"class":196},[50,1290,1291],{"class":196}," typeof",[50,1293,1294],{"class":207}," val ",[50,1296,1297],{"class":196},"===",[50,1299,1300],{"class":311}," 'function'",[50,1302,1303],{"class":196}," ?",[50,1305,1306],{"class":203}," val",[50,1308,1309],{"class":207},"(record) ",[50,1311,230],{"class":196},[50,1313,1314],{"class":207}," val;\n",[50,1316,1317],{"class":52,"line":71},[50,1318,1319],{"class":207},"};\n",[11,1321,1322,1323,1325],{},"That's the \"always callable\" contract the old ",[15,1324,17],{}," 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,1327,1328,1329,1332,1333,1336,1337,1340,1341,1344,1345,1347],{},"The spread order matters: ",[15,1330,1331],{},"props.cell.props"," is spread first, ",[15,1334,1335],{},"props.binder"," second. ",[15,1338,1339],{},"binder"," is the same as ",[15,1342,1343],{},"cell.props"," — it's passed in via a separate prop for historical reasons I'd collapse if I rewrote this. Until then, ",[15,1346,1339],{}," wins on conflict, which is what callers expect.",[1349,1350,892,1352,1355],"h3",{"id":1351},"the-keysortvalueapplied-that-looks-like-a-typo",[15,1353,1354],{},":key=\"sortValue?.applied\""," that looks like a typo",[11,1357,1358,1359,1362,1363,1366,1367,1370,1371,1373,1374,1377],{},"The second thing is that odd ",[15,1360,1361],{},":key=\"props.cell.sortValue?.applied || 'ATCell'\"",". The component's ",[15,1364,1365],{},"\u003Ccomponent :is>"," reacts to changes in the vnode, but the ",[15,1368,1369],{},":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 ",[15,1372,925],{}," outputs, but if the cell's props came from a function of the record ",[504,1375,1376],{},"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.",[30,1379,1381,1384],{"id":1380},"handlevaluebasedonkey-dot-paths-into-rows",[15,1382,1383],{},"handleValueBasedOnKey",": dot-paths into rows",[11,1386,1387,1388,1390,1391,1393],{},"When a column doesn't supply ",[15,1389,531],{}," or ",[15,1392,535],{},", the cell falls back to a default renderer:",[41,1395,1397],{"className":187,"code":1396,"language":189,"meta":46,"style":46},"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",[15,1398,1399,1435,1455,1465,1495,1500,1517,1526,1543,1547,1554,1576,1580,1601],{"__ignoreMap":46},[50,1400,1401,1403,1405,1407,1409,1412,1414,1416,1418,1420,1423,1425,1427,1429,1431,1433],{"class":52,"line":53},[50,1402,952],{"class":196},[50,1404,1126],{"class":203},[50,1406,214],{"class":196},[50,1408,420],{"class":207},[50,1410,1411],{"class":226},"cell",[50,1413,230],{"class":196},[50,1415,204],{"class":203},[50,1417,208],{"class":207},[50,1419,211],{"class":203},[50,1421,1422],{"class":207},">, ",[50,1424,759],{"class":226},[50,1426,230],{"class":196},[50,1428,428],{"class":203},[50,1430,440],{"class":207},[50,1432,362],{"class":196},[50,1434,968],{"class":207},[50,1436,1437,1440,1443,1445,1447,1449,1452],{"class":52,"line":59},[50,1438,1439],{"class":196},"  const",[50,1441,1442],{"class":217}," defaultHeadCell",[50,1444,214],{"class":196},[50,1446,976],{"class":203},[50,1448,1073],{"class":207},[50,1450,1451],{"class":311},"'span'",[50,1453,1454],{"class":207},", {\n",[50,1456,1457,1460,1463],{"class":52,"line":65},[50,1458,1459],{"class":207},"    class: ",[50,1461,1462],{"class":311},"'text-[16px] text-secondary font-medium capitalize'",[50,1464,999],{"class":207},[50,1466,1467,1470,1473,1476,1478,1480,1482,1485,1488,1490,1492],{"class":52,"line":71},[50,1468,1469],{"class":207},"    innerHTML: ",[50,1471,1472],{"class":196},"typeof",[50,1474,1475],{"class":207}," cell.title ",[50,1477,1297],{"class":196},[50,1479,1300],{"class":311},[50,1481,1303],{"class":196},[50,1483,1484],{"class":207}," cell.",[50,1486,1487],{"class":203},"title",[50,1489,1309],{"class":207},[50,1491,230],{"class":196},[50,1493,1494],{"class":207}," cell.title,\n",[50,1496,1497],{"class":52,"line":77},[50,1498,1499],{"class":207},"  });\n",[50,1501,1502,1504,1507,1509,1511,1513,1515],{"class":52,"line":83},[50,1503,1439],{"class":196},[50,1505,1506],{"class":217}," defaultBodyCell",[50,1508,214],{"class":196},[50,1510,976],{"class":203},[50,1512,1073],{"class":207},[50,1514,1451],{"class":311},[50,1516,1454],{"class":207},[50,1518,1519,1521,1524],{"class":52,"line":89},[50,1520,1459],{"class":207},[50,1522,1523],{"class":311},"'text-[16px] text-foreground capitalize'",[50,1525,999],{"class":207},[50,1527,1528,1530,1532,1535,1538,1540],{"class":52,"line":95},[50,1529,1469],{"class":207},[50,1531,1383],{"class":203},[50,1533,1534],{"class":207},"(cell.key ",[50,1536,1537],{"class":196},"as",[50,1539,233],{"class":217},[50,1541,1542],{"class":207},", record),\n",[50,1544,1545],{"class":52,"line":101},[50,1546,1499],{"class":207},[50,1548,1549,1551],{"class":52,"line":107},[50,1550,1270],{"class":196},[50,1552,1553],{"class":207}," (props.head) {\n",[50,1555,1556,1559,1562,1564,1566,1568,1571,1573],{"class":52,"line":113},[50,1557,1558],{"class":196},"    return",[50,1560,1561],{"class":207}," cell.headRender ",[50,1563,987],{"class":196},[50,1565,1484],{"class":207},[50,1567,531],{"class":203},[50,1569,1570],{"class":207},"() ",[50,1572,230],{"class":196},[50,1574,1575],{"class":207}," defaultHeadCell;\n",[50,1577,1578],{"class":52,"line":120},[50,1579,110],{"class":207},[50,1581,1582,1584,1587,1589,1591,1593,1596,1598],{"class":52,"line":126},[50,1583,973],{"class":196},[50,1585,1586],{"class":207}," cell.bodyRender ",[50,1588,987],{"class":196},[50,1590,1484],{"class":207},[50,1592,535],{"class":203},[50,1594,1595],{"class":207},"(record, props.rowIndex) ",[50,1597,230],{"class":196},[50,1599,1600],{"class":207}," defaultBodyCell;\n",[50,1602,1603],{"class":52,"line":132},[50,1604,1319],{"class":207},[11,1606,1607,1608,230],{},"The piece worth zooming in on is ",[15,1609,1383],{},[41,1611,1613],{"className":187,"code":1612,"language":189,"meta":46,"style":46},"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",[15,1614,1615,1653,1695,1719,1741,1746,1755,1760],{"__ignoreMap":46},[50,1616,1617,1619,1622,1624,1626,1628,1630,1632,1634,1637,1639,1641,1644,1646,1648,1651],{"class":52,"line":53},[50,1618,952],{"class":196},[50,1620,1621],{"class":203}," handleValueBasedOnKey",[50,1623,214],{"class":196},[50,1625,420],{"class":207},[50,1627,520],{"class":226},[50,1629,230],{"class":196},[50,1631,233],{"class":217},[50,1633,284],{"class":207},[50,1635,1636],{"class":226},"receivedData",[50,1638,230],{"class":196},[50,1640,428],{"class":203},[50,1642,1643],{"class":207},")",[50,1645,230],{"class":196},[50,1647,218],{"class":217},[50,1649,1650],{"class":196}," =>",[50,1652,968],{"class":207},[50,1654,1655,1657,1660,1663,1665,1668,1671,1674,1677,1680,1682,1684,1686,1689,1691,1693],{"class":52,"line":59},[50,1656,973],{"class":196},[50,1658,1659],{"class":207}," key.",[50,1661,1662],{"class":203},"split",[50,1664,1073],{"class":207},[50,1666,1667],{"class":311},"'.'",[50,1669,1670],{"class":207},").",[50,1672,1673],{"class":203},"reduce",[50,1675,1676],{"class":207},"((",[50,1678,1679],{"class":226},"acc",[50,1681,230],{"class":196},[50,1683,218],{"class":217},[50,1685,284],{"class":207},[50,1687,1688],{"class":226},"curr",[50,1690,440],{"class":207},[50,1692,362],{"class":196},[50,1694,968],{"class":207},[50,1696,1697,1700,1703,1706,1708,1711,1713,1716],{"class":52,"line":65},[50,1698,1699],{"class":196},"    if",[50,1701,1702],{"class":207}," (acc ",[50,1704,1705],{"class":196},"&&",[50,1707,1291],{"class":196},[50,1709,1710],{"class":207}," acc ",[50,1712,1297],{"class":196},[50,1714,1715],{"class":311}," 'object'",[50,1717,1718],{"class":207},") {\n",[50,1720,1721,1724,1726,1728,1730,1732,1734,1736,1738],{"class":52,"line":71},[50,1722,1723],{"class":196},"      return",[50,1725,1702],{"class":207},[50,1727,1537],{"class":196},[50,1729,276],{"class":203},[50,1731,208],{"class":207},[50,1733,281],{"class":217},[50,1735,284],{"class":207},[50,1737,287],{"class":217},[50,1739,1740],{"class":207},">)[curr];\n",[50,1742,1743],{"class":52,"line":77},[50,1744,1745],{"class":207},"    }\n",[50,1747,1748,1750,1753],{"class":52,"line":83},[50,1749,1558],{"class":196},[50,1751,1752],{"class":217}," undefined",[50,1754,325],{"class":207},[50,1756,1757],{"class":52,"line":89},[50,1758,1759],{"class":207},"  }, receivedData);\n",[50,1761,1762],{"class":52,"line":95},[50,1763,1319],{"class":207},[11,1765,1766,1767,1770,1771,1773,1774,1777],{},"A column with ",[15,1768,1769],{},"key: 'user.profile.displayName'"," walks the dots and pulls the nested value. This is the single feature that stops consumers from writing a ",[15,1772,535],{}," function 80% of the time. Most tables have three or four columns that are just ",[15,1775,1776],{},"row.foo.bar","; giving them a dot-path means the schema-as-data approach wins one more round.",[11,1779,1780,1781,1784,1785,1788,1789,1791,1792,1794],{},"I'm using ",[15,1782,1783],{},"innerHTML"," for the default renderer. That's deliberate for titles that want to include ",[15,1786,1787],{},"\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 ",[15,1790,535],{}," 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 ",[15,1793,535],{}," for every cell.",[30,1796,1798],{"id":1797},"row-keys-the-fallback-when-nobody-supplies-one","Row keys: the fallback when nobody supplies one",[11,1800,1801,1802,1804,1805,1807,1808,1810],{},"The default Vue ",[15,1803,1369],{}," on rows is ",[15,1806,880],{},", 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 ",[15,1809,211],{},", there's no field you can rely on universally.",[11,1812,1813],{},"The interface is the obvious one:",[41,1815,1817],{"className":187,"code":1816,"language":189,"meta":46,"style":46},"type DataTablePropsWithKey\u003CT> = IDataTableProps\u003CT> & {\n  \u002F** Optional stable key extractor; recommended *\u002F\n  rowKey?: (row: T) => string;\n};\n",[15,1818,1819,1849,1855,1879],{"__ignoreMap":46},[50,1820,1821,1824,1827,1829,1831,1833,1835,1838,1840,1842,1844,1847],{"class":52,"line":53},[50,1822,1823],{"class":196},"type",[50,1825,1826],{"class":203}," DataTablePropsWithKey",[50,1828,208],{"class":207},[50,1830,211],{"class":203},[50,1832,290],{"class":207},[50,1834,639],{"class":196},[50,1836,1837],{"class":203}," IDataTableProps",[50,1839,208],{"class":207},[50,1841,211],{"class":203},[50,1843,290],{"class":207},[50,1845,1846],{"class":196},"&",[50,1848,968],{"class":207},[50,1850,1851],{"class":52,"line":59},[50,1852,1854],{"class":1853},"sJ8bj","  \u002F** Optional stable key extractor; recommended *\u002F\n",[50,1856,1857,1860,1862,1864,1867,1869,1871,1873,1875,1877],{"class":52,"line":65},[50,1858,1859],{"class":203},"  rowKey",[50,1861,273],{"class":196},[50,1863,420],{"class":207},[50,1865,1866],{"class":226},"row",[50,1868,230],{"class":196},[50,1870,428],{"class":203},[50,1872,440],{"class":207},[50,1874,362],{"class":196},[50,1876,233],{"class":217},[50,1878,325],{"class":207},[50,1880,1881],{"class":52,"line":71},[50,1882,1319],{"class":207},[11,1884,1885],{},"In practice, nobody passes it. The fallback had to work for arbitrary rows:",[41,1887,1889],{"className":187,"code":1888,"language":189,"meta":46,"style":46},"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",[15,1890,1891,1925,1955,1972,1976,2000,2007,2053,2067,2071,2075,2098,2126,2155],{"__ignoreMap":46},[50,1892,1893,1896,1899,1901,1904,1906,1908,1910,1912,1914,1916,1919,1921,1923],{"class":52,"line":53},[50,1894,1895],{"class":196},"function",[50,1897,1898],{"class":203}," stableKeyFromObject",[50,1900,1073],{"class":207},[50,1902,1903],{"class":226},"obj",[50,1905,230],{"class":196},[50,1907,276],{"class":203},[50,1909,208],{"class":207},[50,1911,281],{"class":217},[50,1913,284],{"class":207},[50,1915,287],{"class":217},[50,1917,1918],{"class":207},">)",[50,1920,230],{"class":196},[50,1922,233],{"class":217},[50,1924,968],{"class":207},[50,1926,1927,1929,1931,1934,1937,1940,1942,1944,1947,1950,1953],{"class":52,"line":59},[50,1928,1270],{"class":196},[50,1930,420],{"class":207},[50,1932,1933],{"class":311},"'id'",[50,1935,1936],{"class":196}," in",[50,1938,1939],{"class":207}," obj ",[50,1941,1705],{"class":196},[50,1943,1291],{"class":196},[50,1945,1946],{"class":207}," obj.id ",[50,1948,1949],{"class":196},"!==",[50,1951,1952],{"class":311}," 'undefined'",[50,1954,1718],{"class":207},[50,1956,1957,1959,1962,1965,1967,1969],{"class":52,"line":65},[50,1958,1558],{"class":196},[50,1960,1961],{"class":203}," String",[50,1963,1964],{"class":207},"(obj.id ",[50,1966,1537],{"class":196},[50,1968,218],{"class":217},[50,1970,1971],{"class":207},");\n",[50,1973,1974],{"class":52,"line":71},[50,1975,110],{"class":207},[50,1977,1978,1980,1983,1985,1988,1991,1994,1997],{"class":52,"line":77},[50,1979,1439],{"class":196},[50,1981,1982],{"class":217}," keys",[50,1984,214],{"class":196},[50,1986,1987],{"class":207}," Object.",[50,1989,1990],{"class":203},"keys",[50,1992,1993],{"class":207},"(obj).",[50,1995,1996],{"class":203},"sort",[50,1998,1999],{"class":207},"();\n",[50,2001,2002,2004],{"class":52,"line":83},[50,2003,973],{"class":196},[50,2005,2006],{"class":207}," keys\n",[50,2008,2009,2012,2015,2017,2020,2022,2024,2027,2029,2032,2035,2037,2039,2042,2044,2047,2050],{"class":52,"line":89},[50,2010,2011],{"class":207},"    .",[50,2013,2014],{"class":203},"map",[50,2016,1676],{"class":207},[50,2018,2019],{"class":226},"k",[50,2021,440],{"class":207},[50,2023,362],{"class":196},[50,2025,2026],{"class":311}," `${",[50,2028,2019],{"class":207},[50,2030,2031],{"class":311},"}:${",[50,2033,2034],{"class":203},"String",[50,2036,1073],{"class":311},[50,2038,1903],{"class":207},[50,2040,2041],{"class":311},"[",[50,2043,2019],{"class":207},[50,2045,2046],{"class":311},"])",[50,2048,2049],{"class":311},"}`",[50,2051,2052],{"class":207},")\n",[50,2054,2055,2057,2060,2062,2065],{"class":52,"line":95},[50,2056,2011],{"class":207},[50,2058,2059],{"class":203},"join",[50,2061,1073],{"class":207},[50,2063,2064],{"class":311},"'|'",[50,2066,1971],{"class":207},[50,2068,2069],{"class":52,"line":101},[50,2070,170],{"class":207},[50,2072,2073],{"class":52,"line":107},[50,2074,117],{"emptyLinePlaceholder":116},[50,2076,2077,2079,2082,2084,2086,2088,2090,2092,2094,2096],{"class":52,"line":113},[50,2078,1895],{"class":196},[50,2080,2081],{"class":203}," getRowKey",[50,2083,1073],{"class":207},[50,2085,1866],{"class":226},[50,2087,230],{"class":196},[50,2089,428],{"class":203},[50,2091,1643],{"class":207},[50,2093,230],{"class":196},[50,2095,233],{"class":217},[50,2097,968],{"class":207},[50,2099,2100,2102,2104,2106,2109,2111,2113,2115,2117,2120,2123],{"class":52,"line":120},[50,2101,1270],{"class":196},[50,2103,420],{"class":207},[50,2105,1472],{"class":196},[50,2107,2108],{"class":207}," globalProps.rowKey ",[50,2110,1297],{"class":196},[50,2112,1300],{"class":311},[50,2114,440],{"class":207},[50,2116,1281],{"class":196},[50,2118,2119],{"class":207}," globalProps.",[50,2121,2122],{"class":203},"rowKey",[50,2124,2125],{"class":207},"(row);\n",[50,2127,2128,2130,2132,2135,2137,2139,2142,2144,2146,2148,2150,2152],{"class":52,"line":126},[50,2129,973],{"class":196},[50,2131,1898],{"class":203},[50,2133,2134],{"class":207},"(row ",[50,2136,1537],{"class":196},[50,2138,218],{"class":217},[50,2140,2141],{"class":196}," as",[50,2143,276],{"class":203},[50,2145,208],{"class":207},[50,2147,281],{"class":217},[50,2149,284],{"class":207},[50,2151,287],{"class":217},[50,2153,2154],{"class":207},">);\n",[50,2156,2157],{"class":52,"line":132},[50,2158,170],{"class":207},[11,2160,2161],{},"Three small calls, each earning its weight:",[2163,2164,2165,2180,2194],"ul",{},[2166,2167,2168,2175,2176,2179],"li",{},[2169,2170,2171,2174],"strong",{},[15,2172,2173],{},"id"," shortcut first."," Most backend records have one. ",[15,2177,2178],{},"String(obj.id)"," handles numbers, strings, and BigInts without branching. The cast is dishonest in the type system and honest at runtime.",[2166,2181,2182,2185,2186,2189,2190,2193],{},[2169,2183,2184],{},"Sort the keys before hashing."," ",[15,2187,2188],{},"Object.keys()"," returns insertion order. For most objects that order is the same every time, but \"most\" isn't \"all\" — an object rebuilt from ",[15,2191,2192],{},"JSON.parse"," won't necessarily agree with one built from an object literal with the same keys. Sorting kills the whole class.",[2166,2195,2196,2185,2205,2207,2208,2210],{},[2169,2197,2198,2201,2202,181],{},[15,2199,2200],{},"String()"," over ",[15,2203,2204],{},"JSON.stringify",[15,2206,2200],{}," never throws; ",[15,2209,2204],{}," throws on circular references (which happen when you're careless with Pinia). The goal is a key that's stable, not a serializer.",[11,2212,2213,2214,2216,2217,2220,2221,2223,2224,2227],{},"The key is used both for Vue's reconciliation ",[504,2215,1376],{}," for selection. That's the invariant that matters. If the two used different notions of identity — ",[15,2218,2219],{},":key=\"i\""," and selection using ",[15,2222,1297],{}," — a sort could drop checkboxes while keeping the data. Using ",[15,2225,2226],{},"getRowKey"," in both places means they can't drift.",[30,2229,2231,2232],{"id":2230},"selection-as-a-mapstring-t","Selection as a ",[15,2233,2234],{},"Map\u003Cstring, T>",[11,2236,2237,2238,2241,2242,2245],{},"There's no separate selection state. The component holds ",[15,2239,2240],{},"localSelectedItems"," (a ",[15,2243,2244],{},"shallowRef\u003CT[]>",") and derives a Map by key:",[41,2247,2249],{"className":187,"code":2248,"language":189,"meta":46,"style":46},"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",[15,2250,2251,2268,2294,2324,2331],{"__ignoreMap":46},[50,2252,2253,2255,2258,2260,2262,2264,2266],{"class":52,"line":53},[50,2254,952],{"class":196},[50,2256,2257],{"class":217}," hashedSelectedItems",[50,2259,214],{"class":196},[50,2261,960],{"class":203},[50,2263,963],{"class":207},[50,2265,362],{"class":196},[50,2267,968],{"class":207},[50,2269,2270,2272,2275,2277,2280,2283,2285,2287,2289,2291],{"class":52,"line":59},[50,2271,1439],{"class":196},[50,2273,2274],{"class":217}," map",[50,2276,214],{"class":196},[50,2278,2279],{"class":196}," new",[50,2281,2282],{"class":203}," Map",[50,2284,208],{"class":207},[50,2286,281],{"class":217},[50,2288,284],{"class":207},[50,2290,211],{"class":203},[50,2292,2293],{"class":207},">();\n",[50,2295,2296,2299,2302,2304,2307,2309,2311,2314,2317,2319,2321],{"class":52,"line":65},[50,2297,2298],{"class":207},"  localSelectedItems.value.",[50,2300,2301],{"class":203},"forEach",[50,2303,1676],{"class":207},[50,2305,2306],{"class":226},"x",[50,2308,440],{"class":207},[50,2310,362],{"class":196},[50,2312,2313],{"class":207}," map.",[50,2315,2316],{"class":203},"set",[50,2318,1073],{"class":207},[50,2320,2226],{"class":203},[50,2322,2323],{"class":207},"(x), x));\n",[50,2325,2326,2328],{"class":52,"line":71},[50,2327,973],{"class":196},[50,2329,2330],{"class":207}," map;\n",[50,2332,2333],{"class":52,"line":77},[50,2334,1153],{"class":207},[11,2336,2337],{},"The selection column's header checkbox checks \"are all items selected\" with one array-scan:",[41,2339,2341],{"className":187,"code":2340,"language":189,"meta":46,"style":46},"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",[15,2342,2343,2360,2385,2400,2404,2436],{"__ignoreMap":46},[50,2344,2345,2347,2350,2352,2354,2356,2358],{"class":52,"line":53},[50,2346,952],{"class":196},[50,2348,2349],{"class":217}," isAllDataSelected",[50,2351,214],{"class":196},[50,2353,960],{"class":203},[50,2355,963],{"class":207},[50,2357,362],{"class":196},[50,2359,968],{"class":207},[50,2361,2362,2364,2367,2369,2372,2375,2377,2380,2382],{"class":52,"line":59},[50,2363,1439],{"class":196},[50,2365,2366],{"class":217}," atData",[50,2368,214],{"class":196},[50,2370,2371],{"class":207}," localItems.value.",[50,2373,2374],{"class":203},"filter",[50,2376,1676],{"class":207},[50,2378,2379],{"class":226},"item",[50,2381,440],{"class":207},[50,2383,2384],{"class":196},"=>\n",[50,2386,2387,2390,2393,2395,2397],{"class":52,"line":65},[50,2388,2389],{"class":207},"    hashedSelectedItems.value.",[50,2391,2392],{"class":203},"has",[50,2394,1073],{"class":207},[50,2396,2226],{"class":203},[50,2398,2399],{"class":207},"(item))\n",[50,2401,2402],{"class":52,"line":71},[50,2403,1148],{"class":207},[50,2405,2406,2408,2411,2414,2417,2419,2421,2424,2426,2428,2431,2434],{"class":52,"line":77},[50,2407,973],{"class":196},[50,2409,2410],{"class":207}," atData.",[50,2412,2413],{"class":217},"length",[50,2415,2416],{"class":196}," ===",[50,2418,2371],{"class":207},[50,2420,2413],{"class":217},[50,2422,2423],{"class":196}," &&",[50,2425,2371],{"class":207},[50,2427,2413],{"class":217},[50,2429,2430],{"class":196}," >",[50,2432,2433],{"class":217}," 0",[50,2435,325],{"class":207},[50,2437,2438],{"class":52,"line":83},[50,2439,1153],{"class":207},[11,2441,2442],{},"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):",[41,2444,2446],{"className":187,"code":2445,"language":189,"meta":46,"style":46},"modelValue: hashedSelectedItems.value.has(getRowKey(rowData)),\n",[15,2447,2448],{"__ignoreMap":46},[50,2449,2450,2453,2456,2458,2460,2462],{"class":52,"line":53},[50,2451,2452],{"class":203},"modelValue",[50,2454,2455],{"class":207},": hashedSelectedItems.value.",[50,2457,2392],{"class":203},[50,2459,1073],{"class":207},[50,2461,2226],{"class":203},[50,2463,2464],{"class":207},"(rowData)),\n",[11,2466,2467,2468,2471],{},"Without the computed Map, each checkbox would do ",[15,2469,2470],{},"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,2473,2474,2475,2478],{},"The selection column itself is built on demand. The consumer either declares it as a column (",[15,2476,2477],{},"type: 'selection'",") or doesn't:",[41,2480,2482],{"className":187,"code":2481,"language":189,"meta":46,"style":46},"if (hasSelectColumn) {\n  const selectionColumn = handleSelectionColumn();\n  _columns = _columns.filter((col) => col.type !== 'selection');\n  _columns.unshift(selectionColumn);\n}\n",[15,2483,2484,2492,2506,2536,2547],{"__ignoreMap":46},[50,2485,2486,2489],{"class":52,"line":53},[50,2487,2488],{"class":196},"if",[50,2490,2491],{"class":207}," (hasSelectColumn) {\n",[50,2493,2494,2496,2499,2501,2504],{"class":52,"line":59},[50,2495,1439],{"class":196},[50,2497,2498],{"class":217}," selectionColumn",[50,2500,214],{"class":196},[50,2502,2503],{"class":203}," handleSelectionColumn",[50,2505,1999],{"class":207},[50,2507,2508,2511,2513,2516,2518,2520,2523,2525,2527,2530,2532,2534],{"class":52,"line":65},[50,2509,2510],{"class":207},"  _columns ",[50,2512,639],{"class":196},[50,2514,2515],{"class":207}," _columns.",[50,2517,2374],{"class":203},[50,2519,1676],{"class":207},[50,2521,2522],{"class":226},"col",[50,2524,440],{"class":207},[50,2526,362],{"class":196},[50,2528,2529],{"class":207}," col.type ",[50,2531,1949],{"class":196},[50,2533,322],{"class":311},[50,2535,1971],{"class":207},[50,2537,2538,2541,2544],{"class":52,"line":71},[50,2539,2540],{"class":207},"  _columns.",[50,2542,2543],{"class":203},"unshift",[50,2545,2546],{"class":207},"(selectionColumn);\n",[50,2548,2549],{"class":52,"line":77},[50,2550,170],{"class":207},[11,2552,2553,2554,2556,2557,2560,2561,2564],{},"The existing ",[15,2555,2477],{}," column, if any, is replaced with the internally-built one and forced to position 0. The consumer's instance is a ",[504,2558,2559],{},"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 ",[15,2562,2563],{},"indeterminate"," state.",[30,2566,2568,2569,2572],{"id":2567},"actions-and-the-markraw-that-earned-its-keep","Actions, and the ",[15,2570,2571],{},"markRaw"," that earned its keep",[11,2574,2575,2576,2579],{},"Actions work the same way: the consumer declares an actions column (or the component auto-appends one if ",[15,2577,2578],{},"actions.length > 0","), and the renderer is built internally:",[41,2581,2583],{"className":187,"code":2582,"language":189,"meta":46,"style":46},"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",[15,2584,2585,2618,2630,2647,2652,2657,2668,2683,2696],{"__ignoreMap":46},[50,2586,2587,2589,2592,2594,2596,2598,2600,2602,2604,2606,2609,2611,2614,2616],{"class":52,"line":53},[50,2588,952],{"class":196},[50,2590,2591],{"class":203}," actionsComponent",[50,2593,230],{"class":196},[50,2595,389],{"class":203},[50,2597,208],{"class":207},[50,2599,211],{"class":203},[50,2601,290],{"class":207},[50,2603,639],{"class":196},[50,2605,420],{"class":207},[50,2607,2608],{"class":226},"rowData",[50,2610,284],{"class":207},[50,2612,2613],{"class":226},"index",[50,2615,440],{"class":207},[50,2617,2384],{"class":196},[50,2619,2620,2623,2625,2627],{"class":52,"line":59},[50,2621,2622],{"class":203},"  h",[50,2624,1073],{"class":207},[50,2626,2571],{"class":203},[50,2628,2629],{"class":207},"(AActionGroup), {\n",[50,2631,2632,2635,2637,2640,2642,2644],{"class":52,"line":65},[50,2633,2634],{"class":207},"    actions: localActions.value ",[50,2636,1537],{"class":196},[50,2638,2639],{"class":203}," IAction",[50,2641,208],{"class":207},[50,2643,287],{"class":217},[50,2645,2646],{"class":207},">[],\n",[50,2648,2649],{"class":52,"line":71},[50,2650,2651],{"class":207},"    record: rowData,\n",[50,2653,2654],{"class":52,"line":77},[50,2655,2656],{"class":207},"    popover: globalProps.popover,\n",[50,2658,2659,2662,2665],{"class":52,"line":83},[50,2660,2661],{"class":207},"    maxMainCount: ",[50,2663,2664],{"class":203},"getVisibleLength",[50,2666,2667],{"class":207},"(rowData),\n",[50,2669,2670,2673,2676,2679,2681],{"class":52,"line":89},[50,2671,2672],{"class":203},"    onUpdateAction",[50,2674,2675],{"class":207},": (",[50,2677,2678],{"class":226},"action",[50,2680,440],{"class":207},[50,2682,2384],{"class":196},[50,2684,2685,2688,2690,2693],{"class":52,"line":95},[50,2686,2687],{"class":203},"      emit",[50,2689,1073],{"class":207},[50,2691,2692],{"class":311},"'updateAction'",[50,2694,2695],{"class":207},", { action, row: rowData, rowIndex: index }),\n",[50,2697,2698],{"class":52,"line":101},[50,2699,1499],{"class":207},[11,2701,2702,2705],{},[15,2703,2704],{},"markRaw(AActionGroup)"," is the line I want to isolate.",[11,2707,2708,2709,2711,2712,2714,2715,567,2718,2721],{},"When you pass a component definition to ",[15,2710,925],{},", Vue touches it during render. Without ",[15,2713,2571],{},", if that component definition ever passes through Vue's reactivity system — a ",[15,2716,2717],{},"ref",[15,2719,2720],{},"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,2723,2724,2725,2728,2729,2731,2732,2735],{},"The render function above runs once per row. For a table with 500 rows, the first render touches ",[15,2726,2727],{},"AActionGroup","'s definition 500 times. ",[15,2730,2571],{}," sets a ",[15,2733,2734],{},"__v_skip"," flag on the object once, and every subsequent render skips the Proxy path.",[11,2737,2738,2739,2741,2742,2745,2746,2748],{},"The cost of ",[15,2740,2571],{}," 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 ",[504,2743,2744],{},"which"," component you pass to ",[15,2747,2571],{},"), not by making the reference reactive.",[11,2750,2751,2752,2754,2755,2757,2758,2761,2762,2764],{},"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 ",[15,2753,925],{},". I hit that on a 1200-row table where ",[15,2756,2727],{}," was passed through a ",[15,2759,2760],{},"shallowRef"," for theming. Adding ",[15,2763,2571],{}," silenced it and dropped the first-paint time noticeably.",[11,2766,2767],{},"The visible-action count can be either a number or a function of the row:",[41,2769,2771],{"className":187,"code":2770,"language":189,"meta":46,"style":46},"const getVisibleLength = (rowData: T) =>\n  typeof globalProps.visibleActionLength === 'function'\n    ? globalProps.visibleActionLength(rowData)\n    : globalProps.visibleActionLength;\n",[15,2772,2773,2794,2807,2820],{"__ignoreMap":46},[50,2774,2775,2777,2780,2782,2784,2786,2788,2790,2792],{"class":52,"line":53},[50,2776,952],{"class":196},[50,2778,2779],{"class":203}," getVisibleLength",[50,2781,214],{"class":196},[50,2783,420],{"class":207},[50,2785,2608],{"class":226},[50,2787,230],{"class":196},[50,2789,428],{"class":203},[50,2791,440],{"class":207},[50,2793,2384],{"class":196},[50,2795,2796,2799,2802,2804],{"class":52,"line":59},[50,2797,2798],{"class":196},"  typeof",[50,2800,2801],{"class":207}," globalProps.visibleActionLength ",[50,2803,1297],{"class":196},[50,2805,2806],{"class":311}," 'function'\n",[50,2808,2809,2812,2814,2817],{"class":52,"line":65},[50,2810,2811],{"class":196},"    ?",[50,2813,2119],{"class":207},[50,2815,2816],{"class":203},"visibleActionLength",[50,2818,2819],{"class":207},"(rowData)\n",[50,2821,2822,2825],{"class":52,"line":71},[50,2823,2824],{"class":196},"    :",[50,2826,2827],{"class":207}," globalProps.visibleActionLength;\n",[11,2829,2830],{},"Which is the only way to do \"show 3 actions for admins, 1 for regular users\" without building a second actions column.",[30,2832,2834],{"id":2833},"sorting-a-three-click-cycle-local-or-remote","Sorting: a three-click cycle, local or remote",[11,2836,2837],{},"Sort state is a two-boolean object:",[41,2839,2841],{"className":187,"code":2840,"language":189,"meta":46,"style":46},"export type ISortValue = { applied: boolean; revert: boolean };\n",[15,2842,2843],{"__ignoreMap":46},[50,2844,2845,2847,2850,2852,2854,2857,2860,2862,2864,2867,2870,2872,2874],{"class":52,"line":53},[50,2846,197],{"class":196},[50,2848,2849],{"class":196}," type",[50,2851,457],{"class":203},[50,2853,214],{"class":196},[50,2855,2856],{"class":207}," { ",[50,2858,2859],{"class":226},"applied",[50,2861,230],{"class":196},[50,2863,335],{"class":217},[50,2865,2866],{"class":207},"; ",[50,2868,2869],{"class":226},"revert",[50,2871,230],{"class":196},[50,2873,335],{"class":217},[50,2875,2876],{"class":207}," };\n",[11,2878,2879,2880,2883],{},"Not ",[15,2881,2882],{},"'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,2885,2886],{},"The toggle cycles through three states:",[41,2888,2890],{"className":187,"code":2889,"language":189,"meta":46,"style":46},"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",[15,2891,2892,2916,2927,2939,2957,2968,2976,2987,2997,3001,3008],{"__ignoreMap":46},[50,2893,2894,2896,2899,2901,2904,2906,2908,2910,2912,2914],{"class":52,"line":53},[50,2895,1895],{"class":196},[50,2897,2898],{"class":203}," handleToggleSortButton",[50,2900,1073],{"class":207},[50,2902,2903],{"class":226},"sorterValue",[50,2905,230],{"class":196},[50,2907,457],{"class":203},[50,2909,1643],{"class":207},[50,2911,230],{"class":196},[50,2913,457],{"class":203},[50,2915,968],{"class":207},[50,2917,2918,2920,2922,2924],{"class":52,"line":59},[50,2919,1270],{"class":196},[50,2921,420],{"class":207},[50,2923,1275],{"class":196},[50,2925,2926],{"class":207},"sorterValue.applied) {\n",[50,2928,2929,2932,2934,2937],{"class":52,"line":65},[50,2930,2931],{"class":207},"    sorterValue.applied ",[50,2933,639],{"class":196},[50,2935,2936],{"class":217}," true",[50,2938,325],{"class":207},[50,2940,2941,2944,2947,2950,2952,2954],{"class":52,"line":71},[50,2942,2943],{"class":207},"  } ",[50,2945,2946],{"class":196},"else",[50,2948,2949],{"class":196}," if",[50,2951,420],{"class":207},[50,2953,1275],{"class":196},[50,2955,2956],{"class":207},"sorterValue.revert) {\n",[50,2958,2959,2962,2964,2966],{"class":52,"line":77},[50,2960,2961],{"class":207},"    sorterValue.revert ",[50,2963,639],{"class":196},[50,2965,2936],{"class":217},[50,2967,325],{"class":207},[50,2969,2970,2972,2974],{"class":52,"line":83},[50,2971,2943],{"class":207},[50,2973,2946],{"class":196},[50,2975,968],{"class":207},[50,2977,2978,2980,2982,2985],{"class":52,"line":89},[50,2979,2931],{"class":207},[50,2981,639],{"class":196},[50,2983,2984],{"class":217}," false",[50,2986,325],{"class":207},[50,2988,2989,2991,2993,2995],{"class":52,"line":95},[50,2990,2961],{"class":207},[50,2992,639],{"class":196},[50,2994,2984],{"class":217},[50,2996,325],{"class":207},[50,2998,2999],{"class":52,"line":101},[50,3000,110],{"class":207},[50,3002,3003,3005],{"class":52,"line":107},[50,3004,973],{"class":196},[50,3006,3007],{"class":207}," sorterValue;\n",[50,3009,3010],{"class":52,"line":113},[50,3011,170],{"class":207},[11,3013,3014,3015,3018],{},"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 ",[504,3016,3017],{},"doesn't"," have the \"unsort\" click.",[11,3020,3021],{},"The actual sorting has two paths: remote and local.",[41,3023,3025],{"className":187,"code":3024,"language":189,"meta":46,"style":46},"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",[15,3026,3027,3045,3061,3073,3091,3095,3102,3115,3121,3125,3129,3147,3163,3173,3179,3183,3187,3220,3237,3257,3261,3273],{"__ignoreMap":46},[50,3028,3029,3031,3034,3036,3039,3041,3043],{"class":52,"line":53},[50,3030,1895],{"class":196},[50,3032,3033],{"class":203}," sortCell",[50,3035,1073],{"class":207},[50,3037,3038],{"class":226},"columnIndex",[50,3040,230],{"class":196},[50,3042,445],{"class":217},[50,3044,1718],{"class":207},[50,3046,3047,3049,3052,3054,3057,3059],{"class":52,"line":59},[50,3048,1439],{"class":196},[50,3050,3051],{"class":217}," column",[50,3053,214],{"class":196},[50,3055,3056],{"class":207}," localColumns.value[columnIndex]",[50,3058,1275],{"class":196},[50,3060,325],{"class":207},[50,3062,3063,3065,3068,3070],{"class":52,"line":65},[50,3064,1439],{"class":196},[50,3066,3067],{"class":217}," sorterFunction",[50,3069,214],{"class":196},[50,3071,3072],{"class":207}," column.sorter;\n",[50,3074,3075,3077,3080,3082,3084,3087,3089],{"class":52,"line":71},[50,3076,1439],{"class":196},[50,3078,3079],{"class":217}," sorterValue",[50,3081,214],{"class":196},[50,3083,2898],{"class":203},[50,3085,3086],{"class":207},"(column.sortValue",[50,3088,1275],{"class":196},[50,3090,1971],{"class":207},[50,3092,3093],{"class":52,"line":77},[50,3094,117],{"emptyLinePlaceholder":116},[50,3096,3097,3099],{"class":52,"line":83},[50,3098,1270],{"class":196},[50,3100,3101],{"class":207}," (globalProps.remote) {\n",[50,3103,3104,3107,3109,3112],{"class":52,"line":89},[50,3105,3106],{"class":203},"    emit",[50,3108,1073],{"class":207},[50,3110,3111],{"class":311},"'updateSorter'",[50,3113,3114],{"class":207},", { column, sorterValue, rowIndex: columnIndex });\n",[50,3116,3117,3119],{"class":52,"line":95},[50,3118,1558],{"class":196},[50,3120,325],{"class":207},[50,3122,3123],{"class":52,"line":101},[50,3124,110],{"class":207},[50,3126,3127],{"class":52,"line":107},[50,3128,117],{"emptyLinePlaceholder":116},[50,3130,3131,3133,3135,3137,3140,3142,3145],{"class":52,"line":113},[50,3132,1270],{"class":196},[50,3134,420],{"class":207},[50,3136,1275],{"class":196},[50,3138,3139],{"class":207},"sorterValue.applied ",[50,3141,1705],{"class":196},[50,3143,3144],{"class":196}," !",[50,3146,2956],{"class":207},[50,3148,3149,3152,3154,3157,3160],{"class":52,"line":120},[50,3150,3151],{"class":207},"    localItems.value ",[50,3153,639],{"class":196},[50,3155,3156],{"class":207}," _.",[50,3158,3159],{"class":203},"cloneDeep",[50,3161,3162],{"class":207},"(globalProps.items);\n",[50,3164,3165,3167,3169,3171],{"class":52,"line":126},[50,3166,3106],{"class":203},[50,3168,1073],{"class":207},[50,3170,3111],{"class":311},[50,3172,3114],{"class":207},[50,3174,3175,3177],{"class":52,"line":132},[50,3176,1558],{"class":196},[50,3178,325],{"class":207},[50,3180,3181],{"class":52,"line":138},[50,3182,110],{"class":207},[50,3184,3185],{"class":52,"line":144},[50,3186,117],{"emptyLinePlaceholder":116},[50,3188,3189,3192,3194,3196,3199,3202,3204,3206,3209,3211,3214,3216,3218],{"class":52,"line":150},[50,3190,3191],{"class":207},"  localItems.value ",[50,3193,639],{"class":196},[50,3195,1106],{"class":207},[50,3197,3198],{"class":196},"...",[50,3200,3201],{"class":207},"localItems.value].",[50,3203,1996],{"class":203},[50,3205,1676],{"class":207},[50,3207,3208],{"class":226},"a",[50,3210,284],{"class":207},[50,3212,3213],{"class":226},"b",[50,3215,440],{"class":207},[50,3217,362],{"class":196},[50,3219,968],{"class":207},[50,3221,3222,3225,3228,3230,3232,3234],{"class":52,"line":156},[50,3223,3224],{"class":196},"    const",[50,3226,3227],{"class":217}," result",[50,3229,214],{"class":196},[50,3231,3067],{"class":203},[50,3233,1275],{"class":196},[50,3235,3236],{"class":207},"(a, b);\n",[50,3238,3239,3241,3244,3246,3249,3252,3254],{"class":52,"line":162},[50,3240,1558],{"class":196},[50,3242,3243],{"class":207}," sorterValue.revert ",[50,3245,987],{"class":196},[50,3247,3248],{"class":196}," -",[50,3250,3251],{"class":207},"result ",[50,3253,230],{"class":196},[50,3255,3256],{"class":207}," result;\n",[50,3258,3259],{"class":52,"line":167},[50,3260,1499],{"class":207},[50,3262,3264,3267,3269,3271],{"class":52,"line":3263},21,[50,3265,3266],{"class":203},"  emit",[50,3268,1073],{"class":207},[50,3270,3111],{"class":311},[50,3272,3114],{"class":207},[50,3274,3276],{"class":52,"line":3275},22,[50,3277,170],{"class":207},[11,3279,3280,3281,3284],{},"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 ",[15,3282,3283],{},"globalProps.items",". That restores original order without needing to remember what it was.",[11,3286,3287],{},"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.",[30,3289,3291,3292,284,3294,284,3296],{"id":3290},"reactivity-shallowref-clonedeep-debouncedwatch","Reactivity: ",[15,3293,2760],{},[15,3295,3159],{},[15,3297,3298],{},"debouncedWatch",[11,3300,3301,3302,284,3305,284,3308,3311,3312,3314,3315,230],{},"The component does not deep-watch the consumer's data. ",[15,3303,3304],{},"localItems",[15,3306,3307],{},"localColumns",[15,3309,3310],{},"localActions",", and ",[15,3313,2240],{}," are all ",[15,3316,2760],{},[41,3318,3320],{"className":187,"code":3319,"language":189,"meta":46,"style":46},"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",[15,3321,3322,3346,3369,3397],{"__ignoreMap":46},[50,3323,3324,3326,3329,3331,3334,3336,3338,3341,3343],{"class":52,"line":53},[50,3325,952],{"class":196},[50,3327,3328],{"class":217}," localItems",[50,3330,214],{"class":196},[50,3332,3333],{"class":203}," shallowRef",[50,3335,208],{"class":207},[50,3337,211],{"class":203},[50,3339,3340],{"class":207},"[]>(_.",[50,3342,3159],{"class":203},[50,3344,3345],{"class":207},"(globalProps.items));\n",[50,3347,3348,3350,3353,3355,3357,3359,3362,3364,3366],{"class":52,"line":59},[50,3349,952],{"class":196},[50,3351,3352],{"class":217}," localColumns",[50,3354,214],{"class":196},[50,3356,3333],{"class":203},[50,3358,208],{"class":207},[50,3360,3361],{"class":203},"IDataTableColumn",[50,3363,208],{"class":207},[50,3365,211],{"class":203},[50,3367,3368],{"class":207},">[]>([]);\n",[50,3370,3371,3373,3376,3378,3380,3382,3385,3387,3389,3392,3394],{"class":52,"line":65},[50,3372,952],{"class":196},[50,3374,3375],{"class":217}," localActions",[50,3377,214],{"class":196},[50,3379,3333],{"class":203},[50,3381,208],{"class":207},[50,3383,3384],{"class":203},"IAction",[50,3386,208],{"class":207},[50,3388,211],{"class":203},[50,3390,3391],{"class":207},">[]>(_.",[50,3393,3159],{"class":203},[50,3395,3396],{"class":207},"(globalProps.actions));\n",[50,3398,3399,3401,3404,3406,3408,3410,3412,3414,3416],{"class":52,"line":71},[50,3400,952],{"class":196},[50,3402,3403],{"class":217}," localSelectedItems",[50,3405,214],{"class":196},[50,3407,3333],{"class":203},[50,3409,208],{"class":207},[50,3411,211],{"class":203},[50,3413,3340],{"class":207},[50,3415,3159],{"class":203},[50,3417,3418],{"class":207},"(globalProps.selectedItems));\n",[11,3420,3421,3422,3424,3425,3428,3429,3431],{},"A ",[15,3423,2717],{}," 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 ",[15,3426,3427],{},".value"," wholesale, never mutates individual rows in place. ",[15,3430,2760],{}," says \"track when the reference changes, don't recurse.\" Which is exactly the contract the component needs.",[11,3433,3434,3435,3438,3439,3441],{},"The cloning is the second half of that story. ",[15,3436,3437],{},"_.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 ",[15,3440,3304],{}," 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,3443,3444,3445,230],{},"Re-watching uses ",[15,3446,3298],{},[41,3448,3450],{"className":187,"code":3449,"language":189,"meta":46,"style":46},"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",[15,3451,3452,3458,3468,3486,3503,3507,3511,3517,3526,3544,3562],{"__ignoreMap":46},[50,3453,3454,3456],{"class":52,"line":53},[50,3455,3298],{"class":203},[50,3457,979],{"class":207},[50,3459,3460,3463,3465],{"class":52,"line":59},[50,3461,3462],{"class":207},"  () ",[50,3464,362],{"class":196},[50,3466,3467],{"class":207}," globalProps.items,\n",[50,3469,3470,3472,3474,3477,3479,3481,3483],{"class":52,"line":65},[50,3471,3462],{"class":207},[50,3473,362],{"class":196},[50,3475,3476],{"class":207}," (localItems.value ",[50,3478,639],{"class":196},[50,3480,3156],{"class":207},[50,3482,3159],{"class":203},[50,3484,3485],{"class":207},"(globalProps.items)),\n",[50,3487,3488,3491,3494,3497,3500],{"class":52,"line":71},[50,3489,3490],{"class":207},"  { deep: ",[50,3492,3493],{"class":217},"true",[50,3495,3496],{"class":207},", debounce: ",[50,3498,3499],{"class":217},"100",[50,3501,3502],{"class":207}," }\n",[50,3504,3505],{"class":52,"line":77},[50,3506,1971],{"class":207},[50,3508,3509],{"class":52,"line":83},[50,3510,117],{"emptyLinePlaceholder":116},[50,3512,3513,3515],{"class":52,"line":89},[50,3514,3298],{"class":203},[50,3516,979],{"class":207},[50,3518,3519,3521,3523],{"class":52,"line":95},[50,3520,3462],{"class":207},[50,3522,362],{"class":196},[50,3524,3525],{"class":207}," globalProps.selectedItems,\n",[50,3527,3528,3530,3532,3535,3537,3539,3541],{"class":52,"line":101},[50,3529,3462],{"class":207},[50,3531,362],{"class":196},[50,3533,3534],{"class":207}," (localSelectedItems.value ",[50,3536,639],{"class":196},[50,3538,3156],{"class":207},[50,3540,3159],{"class":203},[50,3542,3543],{"class":207},"(globalProps.selectedItems)),\n",[50,3545,3546,3548,3550,3553,3555,3557,3560],{"class":52,"line":107},[50,3547,3490],{"class":207},[50,3549,3493],{"class":217},[50,3551,3552],{"class":207},", immediate: ",[50,3554,3493],{"class":217},[50,3556,3496],{"class":207},[50,3558,3559],{"class":217},"50",[50,3561,3502],{"class":207},[50,3563,3564],{"class":52,"line":113},[50,3565,1971],{"class":207},[11,3567,3568],{},"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,3570,3571,3572,3575],{},"The columns\u002Factions watcher is ",[504,3573,3574],{},"not"," debounced:",[41,3577,3579],{"className":187,"code":3578,"language":189,"meta":46,"style":46},"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",[15,3580,3581,3593,3600,3609,3617,3631,3642,3647,3659],{"__ignoreMap":46},[50,3582,3583,3586,3588,3591],{"class":52,"line":53},[50,3584,3585],{"class":207},"localColumns.value ",[50,3587,639],{"class":196},[50,3589,3590],{"class":203}," handleColumns",[50,3592,1999],{"class":207},[50,3594,3595,3598],{"class":52,"line":59},[50,3596,3597],{"class":203},"watch",[50,3599,979],{"class":207},[50,3601,3602,3604,3606],{"class":52,"line":65},[50,3603,3462],{"class":207},[50,3605,362],{"class":196},[50,3607,3608],{"class":207}," [globalProps.columns, globalProps.actions],\n",[50,3610,3611,3613,3615],{"class":52,"line":71},[50,3612,3462],{"class":207},[50,3614,362],{"class":196},[50,3616,968],{"class":207},[50,3618,3619,3622,3624,3626,3628],{"class":52,"line":77},[50,3620,3621],{"class":207},"    localActions.value ",[50,3623,639],{"class":196},[50,3625,3156],{"class":207},[50,3627,3159],{"class":203},[50,3629,3630],{"class":207},"(globalProps.actions);\n",[50,3632,3633,3636,3638,3640],{"class":52,"line":83},[50,3634,3635],{"class":207},"    localColumns.value ",[50,3637,639],{"class":196},[50,3639,3590],{"class":203},[50,3641,1999],{"class":207},[50,3643,3644],{"class":52,"line":89},[50,3645,3646],{"class":207},"  },\n",[50,3648,3649,3651,3653,3655,3657],{"class":52,"line":95},[50,3650,3490],{"class":207},[50,3652,3493],{"class":217},[50,3654,3552],{"class":207},[50,3656,3493],{"class":217},[50,3658,3502],{"class":207},[50,3660,3661],{"class":52,"line":101},[50,3662,1971],{"class":207},[11,3664,3665,3666,3669],{},"Column and action changes are rare — usually they're declared once at the top of a ",[15,3667,3668],{},"\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.",[30,3671,3673],{"id":3672},"loading-empty-paginated-the-surrounding-chrome","Loading, empty, paginated — the surrounding chrome",[11,3675,3676],{},"The surrounding chrome is a small story that nobody writes down.",[11,3678,3679,3680,230],{},"The loading state is an absolutely-positioned overlay with ",[15,3681,3682],{},"pointer-events-none",[41,3684,3686],{"className":588,"code":3685,"language":590,"meta":46,"style":46},"\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",[15,3687,3688,3695,3711,3721,3725,3730],{"__ignoreMap":46},[50,3689,3690,3692],{"class":52,"line":53},[50,3691,208],{"class":207},[50,3693,3694],{"class":599},"div\n",[50,3696,3697,3700,3702,3705,3708],{"class":52,"line":59},[50,3698,3699],{"class":196},"  v-if",[50,3701,639],{"class":207},[50,3703,3704],{"class":311},"\"",[50,3706,3707],{"class":207},"globalProps.loading",[50,3709,3710],{"class":311},"\"\n",[50,3712,3713,3716,3718],{"class":52,"line":65},[50,3714,3715],{"class":203},"  class",[50,3717,639],{"class":207},[50,3719,3720],{"class":311},"\"bg-white\u002F70 flex pointer-events-none items-center inset-0 justify-center absolute z-10 backdrop-blur-[1px]\"\n",[50,3722,3723],{"class":52,"line":71},[50,3724,603],{"class":207},[50,3726,3727],{"class":52,"line":77},[50,3728,3729],{"class":207},"  \u003CLoadingComp \u002F>\n",[50,3731,3732,3734,3737],{"class":52,"line":83},[50,3733,739],{"class":207},[50,3735,3736],{"class":599},"div",[50,3738,603],{"class":207},[11,3740,3741,3743],{},[15,3742,3682],{}," 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,3745,3746],{},"The empty state is a row inside the table, not a sibling:",[41,3748,3750],{"className":588,"code":3749,"language":590,"meta":46,"style":46},"\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",[15,3751,3752,3786,3791,3796,3801,3806,3810],{"__ignoreMap":46},[50,3753,3754,3756,3758,3760,3763,3765,3767,3769,3771,3773,3776,3778,3780,3782,3784],{"class":52,"line":53},[50,3755,208],{"class":207},[50,3757,557],{"class":599},[50,3759,993],{"class":207},[50,3761,3762],{"class":203},"columns",[50,3764,639],{"class":207},[50,3766,3704],{"class":311},[50,3768,3307],{"class":207},[50,3770,3704],{"class":311},[50,3772,993],{"class":207},[50,3774,3775],{"class":203},"items",[50,3777,639],{"class":207},[50,3779,3704],{"class":311},[50,3781,3304],{"class":207},[50,3783,3704],{"class":311},[50,3785,603],{"class":207},[50,3787,3788],{"class":52,"line":59},[50,3789,3790],{"class":207},"  \u003Ctr v-if=\"localItems.length === 0 && !globalProps.loading\">\n",[50,3792,3793],{"class":52,"line":65},[50,3794,3795],{"class":207},"    \u003Ctd :colspan=\"localColumns.length\" class=\"p-4\">\n",[50,3797,3798],{"class":52,"line":71},[50,3799,3800],{"class":207},"      \u003CNoData :title=\"globalProps.noDataTitle\" \u002F>\n",[50,3802,3803],{"class":52,"line":77},[50,3804,3805],{"class":207},"    \u003C\u002Ftd>\n",[50,3807,3808],{"class":52,"line":83},[50,3809,850],{"class":207},[50,3811,3812,3814,3816],{"class":52,"line":89},[50,3813,739],{"class":207},[50,3815,557],{"class":599},[50,3817,603],{"class":207},[11,3819,3820,3821,3823,3824,3827,3828,3831],{},"Piping it through the ",[15,3822,570],{},"'s slot is how it inherits the table's border and spacing. A sibling ",[15,3825,3826],{},"\u003Cdiv>"," would need separate styles to line up; a ",[15,3829,3830],{},"\u003Ctr colspan>"," gets layout for free.",[11,3833,3834,3835,3838],{},"Pagination is optional and driven by the ",[15,3836,3837],{},"pagination"," prop:",[41,3840,3842],{"className":588,"code":3841,"language":590,"meta":46,"style":46},"\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",[15,3843,3844,3870,3875,3880,3885,3890,3895,3900,3905],{"__ignoreMap":46},[50,3845,3846,3848,3850,3853,3855,3857,3860,3862,3864,3866,3868],{"class":52,"line":53},[50,3847,208],{"class":207},[50,3849,3736],{"class":599},[50,3851,3852],{"class":196}," v-if",[50,3854,639],{"class":207},[50,3856,3704],{"class":311},[50,3858,3859],{"class":207},"globalProps.pagination ",[50,3861,1705],{"class":196},[50,3863,3144],{"class":196},[50,3865,3707],{"class":207},[50,3867,3704],{"class":311},[50,3869,603],{"class":207},[50,3871,3872],{"class":52,"line":59},[50,3873,3874],{"class":207},"  \u003CAPagination\n",[50,3876,3877],{"class":52,"line":65},[50,3878,3879],{"class":207},"    :model-value=\"globalProps.pagination.modelValue\"\n",[50,3881,3882],{"class":52,"line":71},[50,3883,3884],{"class":207},"    :page-size=\"globalProps.pagination.pageSize\"\n",[50,3886,3887],{"class":52,"line":77},[50,3888,3889],{"class":207},"    :total-items=\"globalProps.pagination.totalItems\"\n",[50,3891,3892],{"class":52,"line":83},[50,3893,3894],{"class":207},"    @update:page-size=\"globalProps.pagination.onPageSizeChange\"\n",[50,3896,3897],{"class":52,"line":89},[50,3898,3899],{"class":207},"    @update:model-value=\"globalProps.pagination.onUpdateCurrentPage\"\n",[50,3901,3902],{"class":52,"line":95},[50,3903,3904],{"class":207},"  \u002F>\n",[50,3906,3907,3909,3911],{"class":52,"line":101},[50,3908,739],{"class":207},[50,3910,3736],{"class":599},[50,3912,603],{"class":207},[11,3914,3915,3916,3918,3919,284,3922,3925],{},"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 ",[15,3917,3837],{}," object as fields (",[15,3920,3921],{},"onPageSizeChange",[15,3923,3924],{},"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.",[30,3927,3929],{"id":3928},"what-id-change-on-a-fourth-rewrite","What I'd change on a fourth rewrite",[11,3931,3932],{},"A few places where today's code isn't the version I'd write fresh:",[2163,3934,3935,3948,3964,3979,3985],{},[2166,3936,3937,3947],{},[2169,3938,892,3939,3941,3942,3944,3945,181],{},[15,3940,1339],{}," vs ",[15,3943,1343],{}," duplication in ",[15,3946,560],{}," 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.",[2166,3949,3950,3955,3956,3959,3960,3963],{},[2169,3951,3952,3954],{},[15,3953,1783],{}," for default cells."," The footgun is real, the ergonomics are worth it. If I did it over I'd either go ",[15,3957,3958],{},"textContent"," by default and make HTML opt-in via a column field (",[15,3961,3962],{},"unsafe: true","), or commit fully to a slot-based approach. Both are refactors; neither is urgent.",[2166,3965,3966,3971,3972,3975,3976,3978],{},[2169,3967,892,3968,3970],{},[15,3969,770],{}," cast in the header."," Works, but an optional ",[15,3973,3974],{},"record?: S"," on ",[15,3977,560],{}," would be more honest — headers don't have a record, say so in the type.",[2166,3980,3981,3984],{},[2169,3982,3983],{},"The hardcoded debounce values (50ms, 100ms)."," Should be props. Three minutes of work; I keep not doing it.",[2166,3986,3987,3997],{},[2169,3988,3989,3990,3993,3994,3996],{},"The computed ",[15,3991,3992],{},"Cell"," paired with ",[15,3995,1365],{}," 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,3999,4000,4001,4003,4004,4006,4007,4009],{},"None of those are the reason I'd recommend this pattern. The reason is smaller and duller: the column-as-data schema, the ",[15,4002,2760],{}," + clone + debounce rhythm, the Map-keyed selection, the ",[15,4005,2571],{}," 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 ",[15,4008,24],{}," — the shape it landed in is the shape of a thing I stopped rewriting.",[4011,4012,4013],"style",{},"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":46,"searchDepth":59,"depth":59,"links":4015},[4016,4018,4020,4025,4027,4028,4030,4032,4033,4035,4036],{"id":32,"depth":59,"text":4017},"From IColumn class to typed interface",{"id":550,"depth":59,"text":4019},"The render trio: ATHead, ATBody, ATCell",{"id":919,"depth":59,"text":4021,"children":4022},"ATCell: one component, two shapes, built via h()",[4023],{"id":1351,"depth":65,"text":4024},"The :key=\"sortValue?.applied\" that looks like a typo",{"id":1380,"depth":59,"text":4026},"handleValueBasedOnKey: dot-paths into rows",{"id":1797,"depth":59,"text":1798},{"id":2230,"depth":59,"text":4029},"Selection as a Map\u003Cstring, T>",{"id":2567,"depth":59,"text":4031},"Actions, and the markRaw that earned its keep",{"id":2833,"depth":59,"text":2834},{"id":3290,"depth":59,"text":4034},"Reactivity: shallowRef, cloneDeep, debouncedWatch",{"id":3672,"depth":59,"text":3673},{"id":3928,"depth":59,"text":3929},"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.",false,"md",null,[4043,4044,2571,2760,4045,4046,4047,3298,4048,4049],"Vue 3 data table","TypeScript generic component","row selection","column sorting","dot-path resolution","UI component","Nuxt",{},"\u002Fblog\u002Fanatomy-of-adatatable",{"title":6,"description":4038},"blog\u002Fanatomy-of-adatatable","dPM9AYXm2ul244J2Efk5i11TX9RuYOkr5PoHmWcTqgM",{"id":4056,"title":4057,"body":4058,"date":4037,"description":5945,"draft":4039,"extension":4040,"image":4041,"keywords":5946,"lang":4041,"meta":5954,"navigation":116,"path":5955,"seo":5956,"stem":5957,"updatedAt":4037,"__hash__":5958},"blog\u002Fblog\u002Finside-asentinel-pagination.md","Four bugs every infinite-scroll list has — and their fixes",{"type":8,"value":4059,"toc":5928},[4060,4063,4073,4083,4086,4090,4093,4184,4191,4198,4209,4216,4219,4381,4395,4398,4459,4462,4539,4546,4553,4560,4579,4594,4601,4676,4681,4951,4969,4973,4979,5015,5018,5150,5168,5172,5179,5186,5189,5332,5335,5346,5353,5360,5363,5378,5384,5388,5405,5408,5411,5451,5476,5483,5486,5609,5627,5637,5644,5647,5650,5872,5898,5908,5912,5918,5925],[11,4061,4062],{},"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,4064,4065,4066,4069,4070,181],{},"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 ",[15,4067,4068],{},"fetcher()"," instead of ",[15,4071,4072],{},"fetcher(page)",[11,4074,4075,4078,4079,4082],{},[15,4076,4077],{},"ASentinelPagination"," is my answer. It's ~230 lines in a single ",[15,4080,4081],{},".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,4084,4085],{},"I'll frame the whole thing around four bugs I've shipped at least once in earlier attempts.",[30,4087,4089],{"id":4088},"the-api-the-consumer-writes","The API the consumer writes",[11,4091,4092],{},"Before the bugs, here's what a consumer actually writes:",[41,4094,4096],{"className":588,"code":4095,"language":590,"meta":46,"style":46},"\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",[15,4097,4098,4120,4125,4130,4135,4140,4145,4149,4154,4158,4162,4167,4172,4176],{"__ignoreMap":46},[50,4099,4100,4102,4104,4106,4109,4111,4113,4116,4118],{"class":52,"line":53},[50,4101,208],{"class":207},[50,4103,4077],{"class":599},[50,4105,993],{"class":207},[50,4107,4108],{"class":203},"fetch-handler",[50,4110,639],{"class":207},[50,4112,3704],{"class":311},[50,4114,4115],{"class":207},"loadProducts",[50,4117,3704],{"class":311},[50,4119,603],{"class":207},[50,4121,4122],{"class":52,"line":59},[50,4123,4124],{"class":207},"  \u003Ctemplate #card=\"{ item }\">\n",[50,4126,4127],{"class":52,"line":65},[50,4128,4129],{"class":207},"    \u003CProductCard :product=\"item\" \u002F>\n",[50,4131,4132],{"class":52,"line":71},[50,4133,4134],{"class":207},"  \u003C\u002Ftemplate>\n",[50,4136,4137],{"class":52,"line":77},[50,4138,4139],{"class":207},"  \u003Ctemplate #initialLoading=\"{ count }\">\n",[50,4141,4142],{"class":52,"line":83},[50,4143,4144],{"class":207},"    \u003CProductCardSkeleton v-for=\"i in count\" :key=\"i\" \u002F>\n",[50,4146,4147],{"class":52,"line":89},[50,4148,4134],{"class":207},[50,4150,4151],{"class":52,"line":95},[50,4152,4153],{"class":207},"  \u003Ctemplate #loadingMore=\"{ count }\">\n",[50,4155,4156],{"class":52,"line":101},[50,4157,4144],{"class":207},[50,4159,4160],{"class":52,"line":107},[50,4161,4134],{"class":207},[50,4163,4164],{"class":52,"line":113},[50,4165,4166],{"class":207},"  \u003Ctemplate #emptyState>\n",[50,4168,4169],{"class":52,"line":120},[50,4170,4171],{"class":207},"    \u003CNoResults \u002F>\n",[50,4173,4174],{"class":52,"line":126},[50,4175,4134],{"class":207},[50,4177,4178,4180,4182],{"class":52,"line":132},[50,4179,739],{"class":207},[50,4181,4077],{"class":599},[50,4183,603],{"class":207},[11,4185,4186,4187,4190],{},"Four slots, one prop that matters. The handler returns ",[15,4188,4189],{},"{ 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.",[30,4192,4194,4197],{"id":4193},"fetchhandlerlength-arity-based-dispatch",[15,4195,4196],{},"fetchHandler.length",": arity-based dispatch",[11,4199,4200,4201,4204,4205,4208],{},"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 ",[504,4202,4203],{},"other"," shape, consumers who don't care about pagination end up writing ",[15,4206,4207],{},"(_page) => ..."," everywhere and ignoring the argument.",[11,4210,4211,4212,4215],{},"The wrong fix is adding a ",[15,4213,4214],{},"mode: 'paged' | 'stateless'"," prop. It's a config for something the function's own shape already expresses.",[11,4217,4218],{},"The right fix turns out to be inspecting the function at runtime:",[41,4220,4222],{"className":187,"code":4221,"language":189,"meta":46,"style":46},"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",[15,4223,4224,4255,4272,4289,4299,4319,4331,4335,4339,4367,4377],{"__ignoreMap":46},[50,4225,4226,4229,4232,4235,4238,4240,4243,4245,4248,4250,4252],{"class":52,"line":53},[50,4227,4228],{"class":196},"async",[50,4230,4231],{"class":196}," function",[50,4233,4234],{"class":203}," runFetchHandler",[50,4236,4237],{"class":207},"()",[50,4239,230],{"class":196},[50,4241,4242],{"class":203}," Promise",[50,4244,208],{"class":207},[50,4246,4247],{"class":203},"ISPPaginationHandlerResult",[50,4249,208],{"class":207},[50,4251,211],{"class":203},[50,4253,4254],{"class":207},">> {\n",[50,4256,4257,4259,4262,4264,4267,4270],{"class":52,"line":59},[50,4258,1270],{"class":196},[50,4260,4261],{"class":207}," (props.fetchHandler.",[50,4263,2413],{"class":217},[50,4265,4266],{"class":196}," >=",[50,4268,4269],{"class":217}," 1",[50,4271,1718],{"class":207},[50,4273,4274,4276,4279,4281,4284,4286],{"class":52,"line":65},[50,4275,3224],{"class":196},[50,4277,4278],{"class":217}," fn",[50,4280,214],{"class":196},[50,4282,4283],{"class":207}," props.fetchHandler ",[50,4285,1537],{"class":196},[50,4287,4288],{"class":207}," (\n",[50,4290,4291,4294,4296],{"class":52,"line":71},[50,4292,4293],{"class":226},"      p",[50,4295,230],{"class":196},[50,4297,4298],{"class":203}," ISPPaginationMeta\n",[50,4300,4301,4304,4306,4308,4310,4312,4314,4316],{"class":52,"line":77},[50,4302,4303],{"class":207},"    ) ",[50,4305,362],{"class":196},[50,4307,4242],{"class":203},[50,4309,208],{"class":207},[50,4311,4247],{"class":203},[50,4313,208],{"class":207},[50,4315,211],{"class":203},[50,4317,4318],{"class":207},">>;\n",[50,4320,4321,4323,4326,4328],{"class":52,"line":83},[50,4322,1558],{"class":196},[50,4324,4325],{"class":196}," await",[50,4327,4278],{"class":203},[50,4329,4330],{"class":207},"(pagination.value);\n",[50,4332,4333],{"class":52,"line":89},[50,4334,110],{"class":207},[50,4336,4337],{"class":52,"line":95},[50,4338,117],{"emptyLinePlaceholder":116},[50,4340,4341,4343,4345,4347,4349,4351,4353,4355,4357,4359,4361,4363,4365],{"class":52,"line":101},[50,4342,1439],{"class":196},[50,4344,4278],{"class":217},[50,4346,214],{"class":196},[50,4348,4283],{"class":207},[50,4350,1537],{"class":196},[50,4352,359],{"class":207},[50,4354,362],{"class":196},[50,4356,4242],{"class":203},[50,4358,208],{"class":207},[50,4360,4247],{"class":203},[50,4362,208],{"class":207},[50,4364,211],{"class":203},[50,4366,4318],{"class":207},[50,4368,4369,4371,4373,4375],{"class":52,"line":107},[50,4370,973],{"class":196},[50,4372,4325],{"class":196},[50,4374,4278],{"class":203},[50,4376,1999],{"class":207},[50,4378,4379],{"class":52,"line":113},[50,4380,170],{"class":207},[11,4382,4383,4386,4387,4390,4391,4394],{},[15,4384,4385],{},"Function.prototype.length"," returns how many formal parameters the function declares. If the consumer wrote ",[15,4388,4389],{},"loadProducts(p)",", it's 1; if they wrote ",[15,4392,4393],{},"loadProducts()",", it's 0. The component branches on that.",[11,4396,4397],{},"From the consumer's side, both are legal:",[41,4399,4401],{"className":187,"code":4400,"language":189,"meta":46,"style":46},"const loadAllProducts = () => $api('\u002Fproducts');                                   \u002F\u002F stateless\nconst loadProductsPage = (p) => $api('\u002Fproducts', { query: { page: p.current_page } }); \u002F\u002F paged\n",[15,4402,4403,4430],{"__ignoreMap":46},[50,4404,4405,4407,4410,4412,4414,4416,4419,4421,4424,4427],{"class":52,"line":53},[50,4406,952],{"class":196},[50,4408,4409],{"class":203}," loadAllProducts",[50,4411,214],{"class":196},[50,4413,359],{"class":207},[50,4415,362],{"class":196},[50,4417,4418],{"class":203}," $api",[50,4420,1073],{"class":207},[50,4422,4423],{"class":311},"'\u002Fproducts'",[50,4425,4426],{"class":207},");                                   ",[50,4428,4429],{"class":1853},"\u002F\u002F stateless\n",[50,4431,4432,4434,4437,4439,4441,4443,4445,4447,4449,4451,4453,4456],{"class":52,"line":59},[50,4433,952],{"class":196},[50,4435,4436],{"class":203}," loadProductsPage",[50,4438,214],{"class":196},[50,4440,420],{"class":207},[50,4442,11],{"class":226},[50,4444,440],{"class":207},[50,4446,362],{"class":196},[50,4448,4418],{"class":203},[50,4450,1073],{"class":207},[50,4452,4423],{"class":311},[50,4454,4455],{"class":207},", { query: { page: p.current_page } }); ",[50,4457,4458],{"class":1853},"\u002F\u002F paged\n",[11,4460,4461],{},"The TypeScript union in the prop type keeps both signatures honest:",[41,4463,4465],{"className":187,"code":4464,"language":189,"meta":46,"style":46},"export type ISPFetchHandler\u003CT> =\n  | ((pagination: ISPPaginationMeta) => Promise\u003CISPPaginationHandlerResult\u003CT>>)\n  | (() => Promise\u003CISPPaginationHandlerResult\u003CT>>);\n",[15,4466,4467,4485,4517],{"__ignoreMap":46},[50,4468,4469,4471,4473,4476,4478,4480,4482],{"class":52,"line":53},[50,4470,197],{"class":196},[50,4472,2849],{"class":196},[50,4474,4475],{"class":203}," ISPFetchHandler",[50,4477,208],{"class":207},[50,4479,211],{"class":203},[50,4481,290],{"class":207},[50,4483,4484],{"class":196},"=\n",[50,4486,4487,4490,4493,4495,4497,4500,4502,4504,4506,4508,4510,4512,4514],{"class":52,"line":59},[50,4488,4489],{"class":196},"  |",[50,4491,4492],{"class":207}," ((",[50,4494,3837],{"class":226},[50,4496,230],{"class":196},[50,4498,4499],{"class":203}," ISPPaginationMeta",[50,4501,440],{"class":207},[50,4503,362],{"class":196},[50,4505,4242],{"class":203},[50,4507,208],{"class":207},[50,4509,4247],{"class":203},[50,4511,208],{"class":207},[50,4513,211],{"class":203},[50,4515,4516],{"class":207},">>)\n",[50,4518,4519,4521,4524,4526,4528,4530,4532,4534,4536],{"class":52,"line":65},[50,4520,4489],{"class":196},[50,4522,4523],{"class":207}," (() ",[50,4525,362],{"class":196},[50,4527,4242],{"class":203},[50,4529,208],{"class":207},[50,4531,4247],{"class":203},[50,4533,208],{"class":207},[50,4535,211],{"class":203},[50,4537,4538],{"class":207},">>);\n",[11,4540,4541,4542,4545],{},"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 ",[15,4543,4544],{},"(_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.",[30,4547,4549,4552],{"id":4548},"inflight-one-gate-three-derived-readouts",[15,4550,4551],{},"inFlight",": one gate, three derived readouts",[11,4554,4555,4556,4559],{},"This is the classic. User hits the bottom, observer fires, fetch starts. User scrolls a pixel, observer fires ",[504,4557,4558],{},"again"," (the sentinel is still on screen), and a second fetch starts before the first one resolved. Depending on timing, you get:",[2163,4561,4562,4565,4576],{},[2166,4563,4564],{},"Both requests return page 2; items duplicate.",[2166,4566,4567,4568,4571,4572,4575],{},"One fires with ",[15,4569,4570],{},"current_page = 2",", the other with ",[15,4573,4574],{},"current_page = 3","; items are interleaved or page 2 is silently skipped.",[2166,4577,4578],{},"Both return the same page, the consumer's de-dup logic breaks, and the layout shifts.",[11,4580,4581,4582,4585,4586,4589,4590,4593],{},"The naïve fix is one boolean — ",[15,4583,4584],{},"isLoading",". It doesn't quite work because there are ",[504,4587,4588],{},"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 ",[504,4591,4592],{},"this"," kind of load in progress?\" which is the wrong question.",[11,4595,4596,4597,4600],{},"The right question is \"is ",[504,4598,4599],{},"anything"," in progress?\" One source of truth, two derived readouts:",[41,4602,4604],{"className":187,"code":4603,"language":189,"meta":46,"style":46},"const initialLoading = ref(true);\nconst isFetchingMore = ref(false);\nconst inFlight = ref\u003C'init' | 'next' | null>(null);\n",[15,4605,4606,4624,4642],{"__ignoreMap":46},[50,4607,4608,4610,4613,4615,4618,4620,4622],{"class":52,"line":53},[50,4609,952],{"class":196},[50,4611,4612],{"class":217}," initialLoading",[50,4614,214],{"class":196},[50,4616,4617],{"class":203}," ref",[50,4619,1073],{"class":207},[50,4621,3493],{"class":217},[50,4623,1971],{"class":207},[50,4625,4626,4628,4631,4633,4635,4637,4640],{"class":52,"line":59},[50,4627,952],{"class":196},[50,4629,4630],{"class":217}," isFetchingMore",[50,4632,214],{"class":196},[50,4634,4617],{"class":203},[50,4636,1073],{"class":207},[50,4638,4639],{"class":217},"false",[50,4641,1971],{"class":207},[50,4643,4644,4646,4649,4651,4653,4655,4658,4660,4663,4665,4668,4671,4674],{"class":52,"line":65},[50,4645,952],{"class":196},[50,4647,4648],{"class":217}," inFlight",[50,4650,214],{"class":196},[50,4652,4617],{"class":203},[50,4654,208],{"class":207},[50,4656,4657],{"class":311},"'init'",[50,4659,236],{"class":196},[50,4661,4662],{"class":311}," 'next'",[50,4664,236],{"class":196},[50,4666,4667],{"class":217}," null",[50,4669,4670],{"class":207},">(",[50,4672,4673],{"class":217},"null",[50,4675,1971],{"class":207},[11,4677,4678,4680],{},[15,4679,4551],{}," is the gate. The other two are what the template switches on for which skeleton to show. They all get set together:",[41,4682,4684],{"className":187,"code":4683,"language":189,"meta":46,"style":46},"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",[15,4685,4686,4718,4735,4765,4769,4779,4783,4790,4809,4828,4832,4846,4854,4887,4896,4914,4932,4943,4947],{"__ignoreMap":46},[50,4687,4688,4690,4693,4695,4698,4700,4703,4705,4708,4710,4712,4714,4716],{"class":52,"line":53},[50,4689,952],{"class":196},[50,4691,4692],{"class":203}," fetchData",[50,4694,214],{"class":196},[50,4696,4697],{"class":196}," async",[50,4699,420],{"class":207},[50,4701,4702],{"class":226},"mode",[50,4704,230],{"class":196},[50,4706,4707],{"class":311}," 'init'",[50,4709,236],{"class":196},[50,4711,4662],{"class":311},[50,4713,440],{"class":207},[50,4715,362],{"class":196},[50,4717,968],{"class":207},[50,4719,4720,4722,4725,4727,4729,4731,4733],{"class":52,"line":59},[50,4721,1270],{"class":196},[50,4723,4724],{"class":207}," (inFlight.value ",[50,4726,1949],{"class":196},[50,4728,4667],{"class":217},[50,4730,440],{"class":207},[50,4732,1281],{"class":196},[50,4734,325],{"class":207},[50,4736,4737,4739,4742,4744,4746,4748,4750,4752,4755,4758,4761,4763],{"class":52,"line":65},[50,4738,1270],{"class":196},[50,4740,4741],{"class":207}," (mode ",[50,4743,1297],{"class":196},[50,4745,4662],{"class":311},[50,4747,2423],{"class":196},[50,4749,420],{"class":207},[50,4751,1275],{"class":196},[50,4753,4754],{"class":207},"hasNextPage.value ",[50,4756,4757],{"class":196},"||",[50,4759,4760],{"class":207}," isFetchingMore.value)) ",[50,4762,1281],{"class":196},[50,4764,325],{"class":207},[50,4766,4767],{"class":52,"line":71},[50,4768,117],{"emptyLinePlaceholder":116},[50,4770,4771,4774,4776],{"class":52,"line":77},[50,4772,4773],{"class":207},"  inFlight.value ",[50,4775,639],{"class":196},[50,4777,4778],{"class":207}," mode;\n",[50,4780,4781],{"class":52,"line":83},[50,4782,117],{"emptyLinePlaceholder":116},[50,4784,4785,4788],{"class":52,"line":89},[50,4786,4787],{"class":196},"  try",[50,4789,968],{"class":207},[50,4791,4792,4794,4796,4798,4800,4803,4805,4807],{"class":52,"line":95},[50,4793,1699],{"class":196},[50,4795,4741],{"class":207},[50,4797,1297],{"class":196},[50,4799,4707],{"class":311},[50,4801,4802],{"class":207},") initialLoading.value ",[50,4804,639],{"class":196},[50,4806,2936],{"class":217},[50,4808,325],{"class":207},[50,4810,4811,4813,4815,4817,4819,4822,4824,4826],{"class":52,"line":101},[50,4812,1699],{"class":196},[50,4814,4741],{"class":207},[50,4816,1297],{"class":196},[50,4818,4662],{"class":311},[50,4820,4821],{"class":207},") isFetchingMore.value ",[50,4823,639],{"class":196},[50,4825,2936],{"class":217},[50,4827,325],{"class":207},[50,4829,4830],{"class":52,"line":107},[50,4831,117],{"emptyLinePlaceholder":116},[50,4833,4834,4836,4838,4840,4842,4844],{"class":52,"line":113},[50,4835,3224],{"class":196},[50,4837,3227],{"class":217},[50,4839,214],{"class":196},[50,4841,4325],{"class":196},[50,4843,4234],{"class":203},[50,4845,1999],{"class":207},[50,4847,4848,4851],{"class":52,"line":120},[50,4849,4850],{"class":203},"    applyPaginationFromHandlerResult",[50,4852,4853],{"class":207},"(result);\n",[50,4855,4856,4859,4861,4864,4866,4868,4870,4873,4875,4877,4879,4882,4884],{"class":52,"line":126},[50,4857,4858],{"class":207},"    items.value ",[50,4860,639],{"class":196},[50,4862,4863],{"class":207}," mode ",[50,4865,1297],{"class":196},[50,4867,4707],{"class":311},[50,4869,1303],{"class":196},[50,4871,4872],{"class":207}," result.items ",[50,4874,230],{"class":196},[50,4876,1106],{"class":207},[50,4878,3198],{"class":196},[50,4880,4881],{"class":207},"items.value, ",[50,4883,3198],{"class":196},[50,4885,4886],{"class":207},"result.items];\n",[50,4888,4889,4891,4894],{"class":52,"line":132},[50,4890,2943],{"class":207},[50,4892,4893],{"class":196},"finally",[50,4895,968],{"class":207},[50,4897,4898,4900,4902,4904,4906,4908,4910,4912],{"class":52,"line":138},[50,4899,1699],{"class":196},[50,4901,4741],{"class":207},[50,4903,1297],{"class":196},[50,4905,4707],{"class":311},[50,4907,4802],{"class":207},[50,4909,639],{"class":196},[50,4911,2984],{"class":217},[50,4913,325],{"class":207},[50,4915,4916,4918,4920,4922,4924,4926,4928,4930],{"class":52,"line":144},[50,4917,1699],{"class":196},[50,4919,4741],{"class":207},[50,4921,1297],{"class":196},[50,4923,4662],{"class":311},[50,4925,4821],{"class":207},[50,4927,639],{"class":196},[50,4929,2984],{"class":217},[50,4931,325],{"class":207},[50,4933,4934,4937,4939,4941],{"class":52,"line":150},[50,4935,4936],{"class":207},"    inFlight.value ",[50,4938,639],{"class":196},[50,4940,4667],{"class":217},[50,4942,325],{"class":207},[50,4944,4945],{"class":52,"line":156},[50,4946,110],{"class":207},[50,4948,4949],{"class":52,"line":162},[50,4950,1319],{"class":207},[11,4952,4953,4954,4957,4958,4961,4962,4964,4965,4968],{},"Two small disciplines keep this honest. The early return checks ",[15,4955,4956],{},"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 ",[15,4959,4960],{},"isFetchingMore"," is still false. And the release lives in ",[15,4963,4893],{},", not ",[15,4966,4967],{},"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.",[1349,4970,4972],{"id":4971},"the-sentinel-and-the-06-threshold","The sentinel, and the 0.6 threshold",[11,4974,4975,4976,230],{},"The sentinel itself is a 1px div with ",[15,4977,4978],{},"aria-hidden",[41,4980,4982],{"className":588,"code":4981,"language":590,"meta":46,"style":46},"\u003Cdiv ref=\"sentinelRef\" class=\"h-1 min-w-[1px] w-full\" aria-hidden=\"true\" \u002F>\n",[15,4983,4984],{"__ignoreMap":46},[50,4985,4986,4988,4990,4992,4994,4997,5000,5002,5005,5008,5010,5013],{"class":52,"line":53},[50,4987,208],{"class":207},[50,4989,3736],{"class":599},[50,4991,4617],{"class":203},[50,4993,639],{"class":207},[50,4995,4996],{"class":311},"\"sentinelRef\"",[50,4998,4999],{"class":203}," class",[50,5001,639],{"class":207},[50,5003,5004],{"class":311},"\"h-1 min-w-[1px] w-full\"",[50,5006,5007],{"class":203}," aria-hidden",[50,5009,639],{"class":207},[50,5011,5012],{"class":311},"\"true\"",[50,5014,1195],{"class":207},[11,5016,5017],{},"And the observer has two safeguards I want to isolate:",[41,5019,5021],{"className":187,"code":5020,"language":189,"meta":46,"style":46},"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",[15,5022,5023,5037,5051,5069,5084,5107,5117,5121,5125,5146],{"__ignoreMap":46},[50,5024,5025,5028,5030,5032,5035],{"class":52,"line":53},[50,5026,5027],{"class":207},"observer ",[50,5029,639],{"class":196},[50,5031,2279],{"class":196},[50,5033,5034],{"class":203}," IntersectionObserver",[50,5036,979],{"class":207},[50,5038,5039,5042,5045,5047,5049],{"class":52,"line":59},[50,5040,5041],{"class":207},"  (",[50,5043,5044],{"class":226},"entries",[50,5046,440],{"class":207},[50,5048,362],{"class":196},[50,5050,968],{"class":207},[50,5052,5053,5055,5058,5060,5063,5066],{"class":52,"line":65},[50,5054,3224],{"class":196},[50,5056,5057],{"class":217}," entry",[50,5059,214],{"class":196},[50,5061,5062],{"class":207}," entries[",[50,5064,5065],{"class":217},"0",[50,5067,5068],{"class":207},"];\n",[50,5070,5071,5073,5075,5077,5080,5082],{"class":52,"line":71},[50,5072,1699],{"class":196},[50,5074,420],{"class":207},[50,5076,1275],{"class":196},[50,5078,5079],{"class":207},"entry?.isIntersecting) ",[50,5081,1281],{"class":196},[50,5083,325],{"class":207},[50,5085,5086,5088,5090,5092,5095,5097,5099,5102,5104],{"class":52,"line":77},[50,5087,1699],{"class":196},[50,5089,420],{"class":207},[50,5091,1275],{"class":196},[50,5093,5094],{"class":207},"initialLoading.value ",[50,5096,1705],{"class":196},[50,5098,3144],{"class":196},[50,5100,5101],{"class":207},"isFetchingMore.value ",[50,5103,1705],{"class":196},[50,5105,5106],{"class":207}," hasNextPage.value) {\n",[50,5108,5109,5112,5115],{"class":52,"line":83},[50,5110,5111],{"class":196},"      void",[50,5113,5114],{"class":203}," fetchNextPage",[50,5116,1999],{"class":207},[50,5118,5119],{"class":52,"line":89},[50,5120,1745],{"class":207},[50,5122,5123],{"class":52,"line":95},[50,5124,3646],{"class":207},[50,5126,5127,5130,5132,5135,5138,5141,5144],{"class":52,"line":101},[50,5128,5129],{"class":207},"  { root: ",[50,5131,4673],{"class":217},[50,5133,5134],{"class":207},", rootMargin: ",[50,5136,5137],{"class":311},"'0px'",[50,5139,5140],{"class":207},", threshold: ",[50,5142,5143],{"class":217},"0.6",[50,5145,3502],{"class":207},[50,5147,5148],{"class":52,"line":107},[50,5149,1971],{"class":207},[11,5151,5152,4069,5155,5157,5158,5161,5162,5165,5166,181],{},[15,5153,5154],{},"threshold: 0.6",[15,5156,5065],{}," — the sentinel is 1px; at ",[15,5159,5160],{},"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 ",[504,5163,5164],{},"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 ",[15,5167,4551],{},[30,5169,5171],{"id":5170},"resetting-when-the-fetchers-identity-changes","Resetting when the fetcher's identity changes",[11,5173,5174,5175,5178],{},"The user clicks \"Category: Shoes.\" The parent component swaps in a new ",[15,5176,5177],{},"fetchHandler"," that includes the new filter. What should happen: clear the list, fetch page 1 with the new handler, attach a new observer.",[11,5180,5181,5182,5185],{},"What happens if you don't handle it: the old list stays on screen. Page 2 of the ",[504,5183,5184],{},"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,5187,5188],{},"The trigger is a watch on the handler itself:",[41,5190,5192],{"className":187,"code":5191,"language":189,"meta":46,"style":46},"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",[15,5193,5194,5200,5209,5220,5230,5234,5243,5254,5263,5273,5278,5287,5296,5300,5304,5317,5324,5328],{"__ignoreMap":46},[50,5195,5196,5198],{"class":52,"line":53},[50,5197,3597],{"class":203},[50,5199,979],{"class":207},[50,5201,5202,5204,5206],{"class":52,"line":59},[50,5203,3462],{"class":207},[50,5205,362],{"class":196},[50,5207,5208],{"class":207}," props.fetchHandler,\n",[50,5210,5211,5214,5216,5218],{"class":52,"line":65},[50,5212,5213],{"class":196},"  async",[50,5215,359],{"class":207},[50,5217,362],{"class":196},[50,5219,968],{"class":207},[50,5221,5222,5225,5228],{"class":52,"line":71},[50,5223,5224],{"class":207},"    observer?.",[50,5226,5227],{"class":203},"disconnect",[50,5229,1999],{"class":207},[50,5231,5232],{"class":52,"line":77},[50,5233,117],{"emptyLinePlaceholder":116},[50,5235,5236,5238,5240],{"class":52,"line":83},[50,5237,4858],{"class":207},[50,5239,639],{"class":196},[50,5241,5242],{"class":207}," [];\n",[50,5244,5245,5248,5250,5252],{"class":52,"line":89},[50,5246,5247],{"class":207},"    hasNextPage.value ",[50,5249,639],{"class":196},[50,5251,2984],{"class":217},[50,5253,325],{"class":207},[50,5255,5256,5259,5261],{"class":52,"line":95},[50,5257,5258],{"class":207},"    pagination.value ",[50,5260,639],{"class":196},[50,5262,968],{"class":207},[50,5264,5265,5268,5271],{"class":52,"line":101},[50,5266,5267],{"class":207},"      current_page: ",[50,5269,5270],{"class":217},"1",[50,5272,999],{"class":207},[50,5274,5275],{"class":52,"line":107},[50,5276,5277],{"class":207},"      per_page: pagination.value.per_page,\n",[50,5279,5280,5283,5285],{"class":52,"line":113},[50,5281,5282],{"class":207},"      total: ",[50,5284,5065],{"class":217},[50,5286,999],{"class":207},[50,5288,5289,5292,5294],{"class":52,"line":120},[50,5290,5291],{"class":207},"      last_page: ",[50,5293,5270],{"class":217},[50,5295,999],{"class":207},[50,5297,5298],{"class":52,"line":126},[50,5299,159],{"class":207},[50,5301,5302],{"class":52,"line":132},[50,5303,117],{"emptyLinePlaceholder":116},[50,5305,5306,5309,5311,5313,5315],{"class":52,"line":138},[50,5307,5308],{"class":196},"    await",[50,5310,4692],{"class":203},[50,5312,1073],{"class":207},[50,5314,4657],{"class":311},[50,5316,1971],{"class":207},[50,5318,5319,5322],{"class":52,"line":144},[50,5320,5321],{"class":203},"    setupObserver",[50,5323,1999],{"class":207},[50,5325,5326],{"class":52,"line":150},[50,5327,110],{"class":207},[50,5329,5330],{"class":52,"line":156},[50,5331,1971],{"class":207},[11,5333,5334],{},"Four things happen, in order, and all four are load-bearing.",[11,5336,5337,5338,5341,5342,5345],{},"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 ",[15,5339,5340],{},"fetchNextPage()"," against stale pagination state in the middle of the reset — which means a request goes out with ",[15,5343,5344],{},"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,5347,5348,5349,5352],{},"Clear items and pagination next. Keep ",[15,5350,5351],{},"per_page"," because that's often a user preference the filter shouldn't reset. The other three fields go back to defaults.",[11,5354,5355,5356,5359],{},"Fire ",[15,5357,5358],{},"fetchData('init')",". Skeleton shows. Fetch runs with the new handler. Items arrive.",[11,5361,5362],{},"Re-attach the observer. The DOM node is the same one; we're binding a fresh observer instance to the post-reset state.",[11,5364,5365,5366,5369,5370,5373,5374,5377],{},"There's a subtlety here worth writing down. The watch is on ",[15,5367,5368],{},"props.fetchHandler"," — reference identity. If the consumer passes ",[15,5371,5372],{},"() => $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 ",[15,5375,5376],{},"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,5379,5380,5381,5383],{},"I considered having the component accept a separate ",[15,5382,520],{}," 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.",[30,5385,5387],{"id":5386},"observer-cleanup-and-the-leak-it-prevents","Observer cleanup, and the leak it prevents",[11,5389,5390,5391,5393,5394,5396,5397,5399,5400,284,5402,5404],{},"This is the quiet one. A user navigates away from a route that had ",[15,5392,4077],{}," 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 ",[504,5395,1376],{}," to the callback closure, which captured the entire ",[15,5398,3668],{}," scope: ",[15,5401,3775],{},[15,5403,3837],{},", all the refs, the whole component instance.",[11,5406,5407],{},"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,5409,5410],{},"The fix is one call:",[41,5412,5414],{"className":187,"code":5413,"language":189,"meta":46,"style":46},"onBeforeUnmount(() => {\n  observer?.disconnect();\n  observer = null;\n});\n",[15,5415,5416,5427,5436,5447],{"__ignoreMap":46},[50,5417,5418,5421,5423,5425],{"class":52,"line":53},[50,5419,5420],{"class":203},"onBeforeUnmount",[50,5422,963],{"class":207},[50,5424,362],{"class":196},[50,5426,968],{"class":207},[50,5428,5429,5432,5434],{"class":52,"line":59},[50,5430,5431],{"class":207},"  observer?.",[50,5433,5227],{"class":203},[50,5435,1999],{"class":207},[50,5437,5438,5441,5443,5445],{"class":52,"line":65},[50,5439,5440],{"class":207},"  observer ",[50,5442,639],{"class":196},[50,5444,4667],{"class":217},[50,5446,325],{"class":207},[50,5448,5449],{"class":52,"line":71},[50,5450,1153],{"class":207},[11,5452,5453,5454,284,5457,284,5460,284,5463,284,5466,5469,5470,1390,5472,5475],{},"Easily written, easily forgotten. The thing that catches this in code review is a mental checklist: every ",[15,5455,5456],{},"new IntersectionObserver",[15,5458,5459],{},"new MutationObserver",[15,5461,5462],{},"addEventListener",[15,5464,5465],{},"setInterval",[15,5467,5468],{},"requestAnimationFrame"," needs a matching teardown in ",[15,5471,5420],{},[15,5473,5474],{},"onUnmounted",". If you can't point to the teardown, you're leaking. Every time.",[1349,5477,5479,5482],{"id":5478},"v-scroll-reveal-per-card-observer",[15,5480,5481],{},"v-scroll-reveal",": per-card observer",[11,5484,5485],{},"The per-card scroll-reveal directive inside the same component has its own version of this:",[41,5487,5489],{"className":187,"code":5488,"language":189,"meta":46,"style":46},"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",[15,5490,5491,5502,5519,5524,5535,5539,5554,5585,5593,5597],{"__ignoreMap":46},[50,5492,5493,5495,5498,5500],{"class":52,"line":53},[50,5494,952],{"class":196},[50,5496,5497],{"class":217}," vScrollReveal",[50,5499,214],{"class":196},[50,5501,968],{"class":207},[50,5503,5504,5507,5509,5512,5514,5517],{"class":52,"line":59},[50,5505,5506],{"class":203},"  mounted",[50,5508,1073],{"class":207},[50,5510,5511],{"class":226},"el",[50,5513,230],{"class":196},[50,5515,5516],{"class":203}," HTMLElement",[50,5518,1718],{"class":207},[50,5520,5521],{"class":52,"line":65},[50,5522,5523],{"class":1853},"    \u002F\u002F ... setup observer, stash on el._scrollObserver\n",[50,5525,5526,5529,5532],{"class":52,"line":71},[50,5527,5528],{"class":207},"    observer.",[50,5530,5531],{"class":203},"observe",[50,5533,5534],{"class":207},"(el);\n",[50,5536,5537],{"class":52,"line":77},[50,5538,3646],{"class":207},[50,5540,5541,5544,5546,5548,5550,5552],{"class":52,"line":83},[50,5542,5543],{"class":203},"  unmounted",[50,5545,1073],{"class":207},[50,5547,5511],{"class":226},[50,5549,230],{"class":196},[50,5551,5516],{"class":203},[50,5553,1718],{"class":207},[50,5555,5556,5558,5561,5563,5566,5568,5570,5573,5575,5578,5580,5582],{"class":52,"line":89},[50,5557,3224],{"class":196},[50,5559,5560],{"class":217}," observer",[50,5562,214],{"class":196},[50,5564,5565],{"class":207}," (el ",[50,5567,1537],{"class":196},[50,5569,5516],{"class":203},[50,5571,5572],{"class":196}," &",[50,5574,2856],{"class":207},[50,5576,5577],{"class":226},"_scrollObserver",[50,5579,273],{"class":196},[50,5581,5034],{"class":203},[50,5583,5584],{"class":207}," })._scrollObserver;\n",[50,5586,5587,5589,5591],{"class":52,"line":95},[50,5588,5224],{"class":207},[50,5590,5227],{"class":203},[50,5592,1999],{"class":207},[50,5594,5595],{"class":52,"line":101},[50,5596,3646],{"class":207},[50,5598,5599,5602,5604,5607],{"class":52,"line":107},[50,5600,5601],{"class":207},"} ",[50,5603,1537],{"class":196},[50,5605,5606],{"class":196}," const",[50,5608,325],{"class":207},[11,5610,5611,5612,5615,5616,5619,5620,5623,5624,5626],{},"The directive stashes its observer on the element itself (",[15,5613,5614],{},"el._scrollObserver",") so ",[15,5617,5618],{},"unmounted"," can find it later. There's also a ",[15,5621,5622],{},"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. ",[15,5625,5618],{}," 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,5628,5629,5630,5632,5633,5636],{},"The type assertion on ",[15,5631,5577],{}," is uglier than I'd like. A ",[15,5634,5635],{},"Symbol"," key would avoid the cast but would also break DevTools inspection. I picked the version I can actually read.",[30,5638,5640,5641],{"id":5639},"type-guards-without-zod","Type guards without ",[15,5642,5643],{},"zod",[11,5645,5646],{},"Two things sit outside the bug frame but earned their way into the component.",[11,5648,5649],{},"The type guards for pagination metadata:",[41,5651,5653],{"className":187,"code":5652,"language":189,"meta":46,"style":46},"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",[15,5654,5655,5693,5716,5720,5747,5771,5775,5802,5822,5828,5839,5848,5857,5864,5868],{"__ignoreMap":46},[50,5656,5657,5659,5662,5664,5667,5669,5671,5673,5675,5678,5681,5683,5685,5687,5689,5691],{"class":52,"line":53},[50,5658,1895],{"class":196},[50,5660,5661],{"class":203}," isRecord",[50,5663,1073],{"class":207},[50,5665,5666],{"class":226},"value",[50,5668,230],{"class":196},[50,5670,218],{"class":217},[50,5672,1643],{"class":207},[50,5674,230],{"class":196},[50,5676,5677],{"class":226}," value",[50,5679,5680],{"class":196}," is",[50,5682,276],{"class":203},[50,5684,208],{"class":207},[50,5686,281],{"class":217},[50,5688,284],{"class":207},[50,5690,287],{"class":217},[50,5692,221],{"class":207},[50,5694,5695,5697,5699,5702,5704,5706,5708,5710,5712,5714],{"class":52,"line":59},[50,5696,973],{"class":196},[50,5698,1291],{"class":196},[50,5700,5701],{"class":207}," value ",[50,5703,1297],{"class":196},[50,5705,1715],{"class":311},[50,5707,2423],{"class":196},[50,5709,5701],{"class":207},[50,5711,1949],{"class":196},[50,5713,4667],{"class":217},[50,5715,325],{"class":207},[50,5717,5718],{"class":52,"line":65},[50,5719,170],{"class":207},[50,5721,5722,5724,5727,5729,5731,5733,5735,5737,5739,5741,5743,5745],{"class":52,"line":71},[50,5723,1895],{"class":196},[50,5725,5726],{"class":203}," isNumber",[50,5728,1073],{"class":207},[50,5730,5666],{"class":226},[50,5732,230],{"class":196},[50,5734,218],{"class":217},[50,5736,1643],{"class":207},[50,5738,230],{"class":196},[50,5740,5677],{"class":226},[50,5742,5680],{"class":196},[50,5744,445],{"class":217},[50,5746,968],{"class":207},[50,5748,5749,5751,5753,5755,5757,5760,5762,5765,5768],{"class":52,"line":77},[50,5750,973],{"class":196},[50,5752,1291],{"class":196},[50,5754,5701],{"class":207},[50,5756,1297],{"class":196},[50,5758,5759],{"class":311}," 'number'",[50,5761,2423],{"class":196},[50,5763,5764],{"class":207}," Number.",[50,5766,5767],{"class":203},"isFinite",[50,5769,5770],{"class":207},"(value);\n",[50,5772,5773],{"class":52,"line":83},[50,5774,170],{"class":207},[50,5776,5777,5779,5782,5784,5786,5788,5790,5792,5794,5796,5798,5800],{"class":52,"line":89},[50,5778,1895],{"class":196},[50,5780,5781],{"class":203}," isPaginationMeta",[50,5783,1073],{"class":207},[50,5785,5666],{"class":226},[50,5787,230],{"class":196},[50,5789,218],{"class":217},[50,5791,1643],{"class":207},[50,5793,230],{"class":196},[50,5795,5677],{"class":226},[50,5797,5680],{"class":196},[50,5799,4499],{"class":203},[50,5801,968],{"class":207},[50,5803,5804,5806,5808,5810,5813,5816,5818,5820],{"class":52,"line":95},[50,5805,1270],{"class":196},[50,5807,420],{"class":207},[50,5809,1275],{"class":196},[50,5811,5812],{"class":203},"isRecord",[50,5814,5815],{"class":207},"(value)) ",[50,5817,1281],{"class":196},[50,5819,2984],{"class":217},[50,5821,325],{"class":207},[50,5823,5824,5826],{"class":52,"line":101},[50,5825,973],{"class":196},[50,5827,4288],{"class":207},[50,5829,5830,5833,5836],{"class":52,"line":107},[50,5831,5832],{"class":203},"    isNumber",[50,5834,5835],{"class":207},"(value.current_page) ",[50,5837,5838],{"class":196},"&&\n",[50,5840,5841,5843,5846],{"class":52,"line":113},[50,5842,5832],{"class":203},[50,5844,5845],{"class":207},"(value.per_page) ",[50,5847,5838],{"class":196},[50,5849,5850,5852,5855],{"class":52,"line":120},[50,5851,5832],{"class":203},[50,5853,5854],{"class":207},"(value.total) ",[50,5856,5838],{"class":196},[50,5858,5859,5861],{"class":52,"line":126},[50,5860,5832],{"class":203},[50,5862,5863],{"class":207},"(value.last_page)\n",[50,5865,5866],{"class":52,"line":132},[50,5867,1148],{"class":207},[50,5869,5870],{"class":52,"line":138},[50,5871,170],{"class":207},[11,5873,5874,5875,5877,5878,5881,5882,5885,5886,5889,5890,5893,5894,5897],{},"No ",[15,5876,5643],{},", no ",[15,5879,5880],{},"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 ",[15,5883,5884],{},"Number.isFinite"," check matters — without it, ",[15,5887,5888],{},"NaN"," and ",[15,5891,5892],{},"Infinity"," pass ",[15,5895,5896],{},"typeof === 'number'"," and you end up with \"page 3 of Infinity\" behaviour downstream.",[11,5899,892,5900,5903,5904,5907],{},[15,5901,5902],{},"void fetchNextPage()"," in the observer callback is tiny and deliberate. The callback isn't async, so awaiting does nothing. Floating promises trip most linters. ",[15,5905,5906],{},"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.",[30,5909,5911],{"id":5910},"a-summary-of-the-four-defenses","A summary of the four defenses",[11,5913,5914,5915,5917],{},"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, ",[15,5916,4551],{}," 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,5919,5920,5921,5924],{},"The thing that made this component stop being a bug factory was seeing the four failure modes above and writing ",[504,5922,5923],{},"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.",[4011,5926,5927],{},"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":46,"searchDepth":59,"depth":59,"links":5929},[5930,5931,5933,5937,5938,5942,5944],{"id":4088,"depth":59,"text":4089},{"id":4193,"depth":59,"text":5932},"fetchHandler.length: arity-based dispatch",{"id":4548,"depth":59,"text":5934,"children":5935},"inFlight: one gate, three derived readouts",[5936],{"id":4971,"depth":65,"text":4972},{"id":5170,"depth":59,"text":5171},{"id":5386,"depth":59,"text":5387,"children":5939},[5940],{"id":5478,"depth":65,"text":5941},"v-scroll-reveal: per-card observer",{"id":5639,"depth":59,"text":5943},"Type guards without zod",{"id":5910,"depth":59,"text":5911},"The four bugs every infinite-scroll list eventually ships, and the specific lines inside a 230-line Vue component that prevent each one.",[5947,5948,5949,5950,3837,5951,5952,5953,4049],"infinite scroll","Vue 3","IntersectionObserver","AbortController","arity dispatch","memory leak","TypeScript",{},"\u002Fblog\u002Finside-asentinel-pagination",{"title":4057,"description":5945},"blog\u002Finside-asentinel-pagination","ZwikI_G7FWOlUPDxnoWRYnuZPGn789bghQLxY1djWOQ",{"id":5960,"title":5961,"body":5962,"date":4037,"description":9356,"draft":4039,"extension":4040,"image":4041,"keywords":9357,"lang":4041,"meta":9365,"navigation":116,"path":9366,"seo":9367,"stem":9368,"updatedAt":4037,"__hash__":9369},"blog\u002Fblog\u002Finside-atell-input-phone-validation.md","Inside ATellInput — a multi-country phone input in Vue",{"type":8,"value":5963,"toc":9336},[5964,5975,5981,5985,5988,6094,6101,6161,6190,6194,6197,6223,6233,6236,6696,6709,6732,6738,6760,6764,6767,6856,6881,6893,6896,7090,7093,7140,7146,7314,7325,7492,7502,7509,7563,7572,7579,7586,7594,7742,7749,7752,7857,7867,7870,7986,8002,8009,8015,8113,8120,8132,8146,8156,8159,8238,8251,8255,8265,8333,8352,8355,8517,8540,8543,8715,8733,8736,8743,8853,8871,8875,8878,8885,8888,8900,8949,8952,8956,8963,9016,9044,9051,9054,9141,9150,9154,9160,9235,9242,9246,9263,9267,9270,9330,9333],[11,5965,5966,5967,5970,5971,5974],{},"Phone inputs are the field I've rewritten most often in my career. Not because the concept is hard — it isn't — but because every version has eventually produced a bug I didn't see coming. A country with a dial code I'd never heard of. A user who pasted ",[15,5968,5969],{},"+20 010 6610 5963"," and got rejected because the ",[15,5972,5973],{},"+20"," duplicated the country code that was already selected. A number that validated client-side and failed at the SMS gateway.",[11,5976,5977,5980],{},[15,5978,5979],{},"ATellInput"," is where that cycle ended, for now. Two Vue components — a country dropdown and a tel input — sitting on top of a 540-line composable that owns the data, the search, the normalization, and the validation. This post is the pieces that make the component stop being a source of new bugs. I've grouped them around the parts of the system that would otherwise each need their own surprise: data, search, placeholders, input handling, validation, and the small details at the edges.",[30,5982,5984],{"id":5983},"the-api-surface","The API surface",[11,5986,5987],{},"The surface is plain:",[41,5989,5991],{"className":588,"code":5990,"language":590,"meta":46,"style":46},"\u003CATellInput\n  v-model:phone=\"form.phone\"\n  v-model:country=\"form.country\"\n  :allowed-dial-codes=\"['966', '20']\"\n  :show-validation=\"true\"\n  size=\"lg\"\n\u002F>\n",[15,5992,5993,6000,6019,6037,6064,6079,6089],{"__ignoreMap":46},[50,5994,5995,5997],{"class":52,"line":53},[50,5996,208],{"class":207},[50,5998,5999],{"class":599},"ATellInput\n",[50,6001,6002,6005,6007,6010,6012,6014,6017],{"class":52,"line":59},[50,6003,6004],{"class":203},"  v-model",[50,6006,230],{"class":207},[50,6008,6009],{"class":203},"phone",[50,6011,639],{"class":207},[50,6013,3704],{"class":311},[50,6015,6016],{"class":207},"form.phone",[50,6018,3710],{"class":311},[50,6020,6021,6023,6025,6028,6030,6032,6035],{"class":52,"line":65},[50,6022,6004],{"class":203},[50,6024,230],{"class":207},[50,6026,6027],{"class":203},"country",[50,6029,639],{"class":207},[50,6031,3704],{"class":311},[50,6033,6034],{"class":207},"form.country",[50,6036,3710],{"class":311},[50,6038,6039,6042,6045,6047,6049,6051,6054,6056,6059,6062],{"class":52,"line":71},[50,6040,6041],{"class":207},"  :",[50,6043,6044],{"class":203},"allowed-dial-codes",[50,6046,639],{"class":207},[50,6048,3704],{"class":311},[50,6050,2041],{"class":207},[50,6052,6053],{"class":311},"'966'",[50,6055,284],{"class":207},[50,6057,6058],{"class":311},"'20'",[50,6060,6061],{"class":207},"]",[50,6063,3710],{"class":311},[50,6065,6066,6068,6071,6073,6075,6077],{"class":52,"line":77},[50,6067,6041],{"class":207},[50,6069,6070],{"class":203},"show-validation",[50,6072,639],{"class":207},[50,6074,3704],{"class":311},[50,6076,3493],{"class":217},[50,6078,3710],{"class":311},[50,6080,6081,6084,6086],{"class":52,"line":83},[50,6082,6083],{"class":203},"  size",[50,6085,639],{"class":207},[50,6087,6088],{"class":311},"\"lg\"\n",[50,6090,6091],{"class":52,"line":89},[50,6092,6093],{"class":207},"\u002F>\n",[11,6095,6096,6097,6100],{},"Two separate v-models, not an aggregate. Every form library I've used has eventually had to re-split an aggregated ",[15,6098,6099],{},"{ phone, country }"," into its parts, and the splitter is where the bug hides. Keeping the two halves as independent models avoids that entirely.",[41,6102,6104],{"className":187,"code":6103,"language":189,"meta":46,"style":46},"const phoneModelValue = defineModel\u003Cstring>('phone', { required: true });\nconst countryModelValue = defineModel\u003Cstring>('country', { required: true });\n",[15,6105,6106,6135],{"__ignoreMap":46},[50,6107,6108,6110,6113,6115,6118,6120,6122,6124,6127,6130,6132],{"class":52,"line":53},[50,6109,952],{"class":196},[50,6111,6112],{"class":217}," phoneModelValue",[50,6114,214],{"class":196},[50,6116,6117],{"class":203}," defineModel",[50,6119,208],{"class":207},[50,6121,281],{"class":217},[50,6123,4670],{"class":207},[50,6125,6126],{"class":311},"'phone'",[50,6128,6129],{"class":207},", { required: ",[50,6131,3493],{"class":217},[50,6133,6134],{"class":207}," });\n",[50,6136,6137,6139,6142,6144,6146,6148,6150,6152,6155,6157,6159],{"class":52,"line":59},[50,6138,952],{"class":196},[50,6140,6141],{"class":217}," countryModelValue",[50,6143,214],{"class":196},[50,6145,6117],{"class":203},[50,6147,208],{"class":207},[50,6149,281],{"class":217},[50,6151,4670],{"class":207},[50,6153,6154],{"class":311},"'country'",[50,6156,6129],{"class":207},[50,6158,3493],{"class":217},[50,6160,6134],{"class":207},[11,6162,6163,6164,6167,6168,4964,6171,6174,6175,6178,6179,6182,6183,6185,6186,6189],{},"The country model holds ",[504,6165,6166],{},"dial digits"," — ",[15,6169,6170],{},"\"20\"",[15,6172,6173],{},"\"EG\""," and not ",[15,6176,6177],{},"\"+20\"",". Dial digits survive URL encoding, localStorage, and database columns without transformation, and they're what ",[15,6180,6181],{},"libphonenumber-js"," internally uses as the calling-code key. If the consumer wants ",[15,6184,6173],{},", they can read ",[15,6187,6188],{},"validation.country.iso2"," off the exposed validation object.",[30,6191,6193],{"id":6192},"three-tiers-of-country-data","Three tiers of country data",[11,6195,6196],{},"The cheapest thing would be to bundle a JSON of every country — names, dial codes, flags — as a static file. That's about 80KB gzipped, and every app that uses this component ships it whether the user ever opens the dropdown or not. I went the other way.",[41,6198,6200],{"className":187,"code":6199,"language":189,"meta":46,"style":46},"const res = await fetch('https:\u002F\u002Frestcountries.com\u002Fv3.1\u002Fall?fields=name,cca2,idd,flags');\n",[15,6201,6202],{"__ignoreMap":46},[50,6203,6204,6206,6209,6211,6213,6216,6218,6221],{"class":52,"line":53},[50,6205,952],{"class":196},[50,6207,6208],{"class":217}," res",[50,6210,214],{"class":196},[50,6212,4325],{"class":196},[50,6214,6215],{"class":203}," fetch",[50,6217,1073],{"class":207},[50,6219,6220],{"class":311},"'https:\u002F\u002Frestcountries.com\u002Fv3.1\u002Fall?fields=name,cca2,idd,flags'",[50,6222,1971],{"class":207},[11,6224,6225,6228,6229,6232],{},[15,6226,6227],{},"restcountries.com"," is a free public API. The ",[15,6230,6231],{},"?fields="," parameter is where the savings are — without it, the response is ~1.5MB of languages, currencies, bordering-country lists, and dozens of other fields a phone input has no business touching. With it, the payload drops to ~80KB. The first time a user opens the dropdown we fetch; after that, localStorage.",[11,6234,6235],{},"Three loading paths, in order:",[41,6237,6239],{"className":187,"code":6238,"language":189,"meta":46,"style":46},"async function getCountries(options?: { force?: boolean }) {\n  if (!force && countries.value.length) return countries.value;\n\n  \u002F\u002F 1. localStorage\n  if (!force && process.client) {\n    try {\n      const cached = localStorage.getItem('ui_phone_countries_v1');\n      if (cached) {\n        const parsed = JSON.parse(cached) as CountryOption[];\n        if (Array.isArray(parsed) && parsed.length) {\n          upsertCountries(parsed);\n          return countries.value;\n        }\n      }\n    } catch { \u002F* fall through *\u002F }\n  }\n\n  \u002F\u002F 2. remote\n  try {\n    const res = await fetch('https:\u002F\u002Frestcountries.com\u002Fv3.1\u002Fall?fields=name,cca2,idd,flags');\n    if (!res.ok) throw new Error(`Failed: ${res.status}`);\n    const data = (await res.json()) as RestCountry[];\n    const normalized = normalizeRestCountries(data);\n    upsertCountries(normalized.length ? normalized : FALLBACK);\n    if (process.client) {\n      try { localStorage.setItem('ui_phone_countries_v1', JSON.stringify(countries.value)); }\n      catch { \u002F* quota, ignore *\u002F }\n    }\n    return countries.value;\n  } catch {\n    \u002F\u002F 3. hardcoded\n    upsertCountries(FALLBACK);\n    return countries.value;\n  }\n}\n",[15,6240,6241,6269,6294,6298,6303,6318,6325,6348,6356,6385,6408,6416,6423,6428,6433,6448,6452,6456,6461,6467,6485,6521,6551,6567,6590,6598,6627,6640,6645,6652,6661,6667,6679,6686,6691],{"__ignoreMap":46},[50,6242,6243,6245,6247,6250,6252,6255,6257,6259,6262,6264,6266],{"class":52,"line":53},[50,6244,4228],{"class":196},[50,6246,4231],{"class":196},[50,6248,6249],{"class":203}," getCountries",[50,6251,1073],{"class":207},[50,6253,6254],{"class":226},"options",[50,6256,273],{"class":196},[50,6258,2856],{"class":207},[50,6260,6261],{"class":226},"force",[50,6263,273],{"class":196},[50,6265,335],{"class":217},[50,6267,6268],{"class":207}," }) {\n",[50,6270,6271,6273,6275,6277,6280,6282,6285,6287,6289,6291],{"class":52,"line":59},[50,6272,1270],{"class":196},[50,6274,420],{"class":207},[50,6276,1275],{"class":196},[50,6278,6279],{"class":207},"force ",[50,6281,1705],{"class":196},[50,6283,6284],{"class":207}," countries.value.",[50,6286,2413],{"class":217},[50,6288,440],{"class":207},[50,6290,1281],{"class":196},[50,6292,6293],{"class":207}," countries.value;\n",[50,6295,6296],{"class":52,"line":65},[50,6297,117],{"emptyLinePlaceholder":116},[50,6299,6300],{"class":52,"line":71},[50,6301,6302],{"class":1853},"  \u002F\u002F 1. localStorage\n",[50,6304,6305,6307,6309,6311,6313,6315],{"class":52,"line":77},[50,6306,1270],{"class":196},[50,6308,420],{"class":207},[50,6310,1275],{"class":196},[50,6312,6279],{"class":207},[50,6314,1705],{"class":196},[50,6316,6317],{"class":207}," process.client) {\n",[50,6319,6320,6323],{"class":52,"line":83},[50,6321,6322],{"class":196},"    try",[50,6324,968],{"class":207},[50,6326,6327,6330,6333,6335,6338,6341,6343,6346],{"class":52,"line":89},[50,6328,6329],{"class":196},"      const",[50,6331,6332],{"class":217}," cached",[50,6334,214],{"class":196},[50,6336,6337],{"class":207}," localStorage.",[50,6339,6340],{"class":203},"getItem",[50,6342,1073],{"class":207},[50,6344,6345],{"class":311},"'ui_phone_countries_v1'",[50,6347,1971],{"class":207},[50,6349,6350,6353],{"class":52,"line":95},[50,6351,6352],{"class":196},"      if",[50,6354,6355],{"class":207}," (cached) {\n",[50,6357,6358,6361,6364,6366,6369,6371,6374,6377,6379,6382],{"class":52,"line":101},[50,6359,6360],{"class":196},"        const",[50,6362,6363],{"class":217}," parsed",[50,6365,214],{"class":196},[50,6367,6368],{"class":217}," JSON",[50,6370,181],{"class":207},[50,6372,6373],{"class":203},"parse",[50,6375,6376],{"class":207},"(cached) ",[50,6378,1537],{"class":196},[50,6380,6381],{"class":203}," CountryOption",[50,6383,6384],{"class":207},"[];\n",[50,6386,6387,6390,6393,6396,6399,6401,6404,6406],{"class":52,"line":107},[50,6388,6389],{"class":196},"        if",[50,6391,6392],{"class":207}," (Array.",[50,6394,6395],{"class":203},"isArray",[50,6397,6398],{"class":207},"(parsed) ",[50,6400,1705],{"class":196},[50,6402,6403],{"class":207}," parsed.",[50,6405,2413],{"class":217},[50,6407,1718],{"class":207},[50,6409,6410,6413],{"class":52,"line":113},[50,6411,6412],{"class":203},"          upsertCountries",[50,6414,6415],{"class":207},"(parsed);\n",[50,6417,6418,6421],{"class":52,"line":120},[50,6419,6420],{"class":196},"          return",[50,6422,6293],{"class":207},[50,6424,6425],{"class":52,"line":126},[50,6426,6427],{"class":207},"        }\n",[50,6429,6430],{"class":52,"line":132},[50,6431,6432],{"class":207},"      }\n",[50,6434,6435,6438,6441,6443,6446],{"class":52,"line":138},[50,6436,6437],{"class":207},"    } ",[50,6439,6440],{"class":196},"catch",[50,6442,2856],{"class":207},[50,6444,6445],{"class":1853},"\u002F* fall through *\u002F",[50,6447,3502],{"class":207},[50,6449,6450],{"class":52,"line":144},[50,6451,110],{"class":207},[50,6453,6454],{"class":52,"line":150},[50,6455,117],{"emptyLinePlaceholder":116},[50,6457,6458],{"class":52,"line":156},[50,6459,6460],{"class":1853},"  \u002F\u002F 2. remote\n",[50,6462,6463,6465],{"class":52,"line":162},[50,6464,4787],{"class":196},[50,6466,968],{"class":207},[50,6468,6469,6471,6473,6475,6477,6479,6481,6483],{"class":52,"line":167},[50,6470,3224],{"class":196},[50,6472,6208],{"class":217},[50,6474,214],{"class":196},[50,6476,4325],{"class":196},[50,6478,6215],{"class":203},[50,6480,1073],{"class":207},[50,6482,6220],{"class":311},[50,6484,1971],{"class":207},[50,6486,6487,6489,6491,6493,6496,6499,6501,6504,6506,6509,6512,6514,6517,6519],{"class":52,"line":3263},[50,6488,1699],{"class":196},[50,6490,420],{"class":207},[50,6492,1275],{"class":196},[50,6494,6495],{"class":207},"res.ok) ",[50,6497,6498],{"class":196},"throw",[50,6500,2279],{"class":196},[50,6502,6503],{"class":203}," Error",[50,6505,1073],{"class":207},[50,6507,6508],{"class":311},"`Failed: ${",[50,6510,6511],{"class":207},"res",[50,6513,181],{"class":311},[50,6515,6516],{"class":207},"status",[50,6518,2049],{"class":311},[50,6520,1971],{"class":207},[50,6522,6523,6525,6528,6530,6532,6535,6538,6541,6544,6546,6549],{"class":52,"line":3275},[50,6524,3224],{"class":196},[50,6526,6527],{"class":217}," data",[50,6529,214],{"class":196},[50,6531,420],{"class":207},[50,6533,6534],{"class":196},"await",[50,6536,6537],{"class":207}," res.",[50,6539,6540],{"class":203},"json",[50,6542,6543],{"class":207},"()) ",[50,6545,1537],{"class":196},[50,6547,6548],{"class":203}," RestCountry",[50,6550,6384],{"class":207},[50,6552,6554,6556,6559,6561,6564],{"class":52,"line":6553},23,[50,6555,3224],{"class":196},[50,6557,6558],{"class":217}," normalized",[50,6560,214],{"class":196},[50,6562,6563],{"class":203}," normalizeRestCountries",[50,6565,6566],{"class":207},"(data);\n",[50,6568,6570,6573,6576,6578,6580,6583,6585,6588],{"class":52,"line":6569},24,[50,6571,6572],{"class":203},"    upsertCountries",[50,6574,6575],{"class":207},"(normalized.",[50,6577,2413],{"class":217},[50,6579,1303],{"class":196},[50,6581,6582],{"class":207}," normalized ",[50,6584,230],{"class":196},[50,6586,6587],{"class":217}," FALLBACK",[50,6589,1971],{"class":207},[50,6591,6593,6595],{"class":52,"line":6592},25,[50,6594,1699],{"class":196},[50,6596,6597],{"class":207}," (process.client) {\n",[50,6599,6601,6604,6607,6610,6612,6614,6616,6619,6621,6624],{"class":52,"line":6600},26,[50,6602,6603],{"class":196},"      try",[50,6605,6606],{"class":207}," { localStorage.",[50,6608,6609],{"class":203},"setItem",[50,6611,1073],{"class":207},[50,6613,6345],{"class":311},[50,6615,284],{"class":207},[50,6617,6618],{"class":217},"JSON",[50,6620,181],{"class":207},[50,6622,6623],{"class":203},"stringify",[50,6625,6626],{"class":207},"(countries.value)); }\n",[50,6628,6630,6633,6635,6638],{"class":52,"line":6629},27,[50,6631,6632],{"class":196},"      catch",[50,6634,2856],{"class":207},[50,6636,6637],{"class":1853},"\u002F* quota, ignore *\u002F",[50,6639,3502],{"class":207},[50,6641,6643],{"class":52,"line":6642},28,[50,6644,1745],{"class":207},[50,6646,6648,6650],{"class":52,"line":6647},29,[50,6649,1558],{"class":196},[50,6651,6293],{"class":207},[50,6653,6655,6657,6659],{"class":52,"line":6654},30,[50,6656,2943],{"class":207},[50,6658,6440],{"class":196},[50,6660,968],{"class":207},[50,6662,6664],{"class":52,"line":6663},31,[50,6665,6666],{"class":1853},"    \u002F\u002F 3. hardcoded\n",[50,6668,6670,6672,6674,6677],{"class":52,"line":6669},32,[50,6671,6572],{"class":203},[50,6673,1073],{"class":207},[50,6675,6676],{"class":217},"FALLBACK",[50,6678,1971],{"class":207},[50,6680,6682,6684],{"class":52,"line":6681},33,[50,6683,1558],{"class":196},[50,6685,6293],{"class":207},[50,6687,6689],{"class":52,"line":6688},34,[50,6690,110],{"class":207},[50,6692,6694],{"class":52,"line":6693},35,[50,6695,170],{"class":207},[11,6697,6698,6699,6701,6702,6705,6706,6708],{},"Four empty-catch blocks look suspicious, and if I'd written them for any other reason I'd push back on them in code review. Here each one is paired with a named degradation that's better than throwing — localStorage throwing on Safari private browsing, ",[15,6700,2192],{}," throwing on a corrupted cache, ",[15,6703,6704],{},"fetch"," throwing on a network failure, ",[15,6707,6609],{}," throwing on quota. Every failure has a fallback one layer down. The user never sees any of it because, from their perspective, nothing went wrong.",[11,6710,6711,6712,6715,6716,6719,6720,6723,6724,6727,6728,6731],{},"The cached shape is already normalized — ",[15,6713,6714],{},"CountryOption[]",", not the raw ",[15,6717,6718],{},"RestCountry[]",". Normalization is the slow part (the parsing isn't), so caching the post-normalization output means subsequent loads skip it. The key has a ",[15,6721,6722],{},"_v1"," suffix. If I ever change the shape of ",[15,6725,6726],{},"CountryOption"," I bump it to ",[15,6729,6730],{},"_v2"," and every user's stale cache dies on the next visit — without a migration function, without a \"if this field exists but that one doesn't\" branch. Versioned cache keys are the cheapest migration strategy there is.",[11,6733,6734,6735,6737],{},"The hardcoded fallback is opinionated: Saudi Arabia and Egypt, because those are the two markets this app targets. If restcountries is down ",[504,6736,1376],{}," the user has no cache, the dropdown works for 95% of actual users. The remaining 5% get a degraded but working experience instead of a blank dropdown and a broken form. If you fork the component for a different market, you change the fallback. That's the right shape of opinionated — explicit, in one place, obvious to change.",[11,6739,6740,6741,6744,6745,6748,6749,6752,6753,6755,6756,6759],{},"Two smaller pieces are worth mentioning in passing. The normalizer picks the \"better\" record when restcountries returns duplicates by ISO2 (which it occasionally does for split territories), scoring each entry by whether it has a flag and a dial code and keeping the higher-scoring one. And ",[15,6742,6743],{},"buildDialCode"," handles the weird shape of ",[15,6746,6747],{},"idd.root + idd.suffixes[0]"," — Barbados is ",[15,6750,6751],{},"+1246",", Egypt is ",[15,6754,5973],{},", and \"just use the root\" gets every ",[15,6757,6758],{},"+1"," country wrong.",[30,6761,6763],{"id":6762},"dual-indexes-and-the-bucketed-dial-map","Dual indexes, and the bucketed dial map",[11,6765,6766],{},"Once countries are loaded, they live three ways at once:",[41,6768,6770],{"className":187,"code":6769,"language":189,"meta":46,"style":46},"const countries = ref\u003CCountryOption[]>([]);\nconst byValue = ref\u003CMap\u003Cstring, CountryOption>>(new Map());\nconst byDialDigits = ref\u003CMap\u003Cstring, CountryOption[]>>(new Map());\n",[15,6771,6772,6790,6824],{"__ignoreMap":46},[50,6773,6774,6776,6779,6781,6783,6785,6787],{"class":52,"line":53},[50,6775,952],{"class":196},[50,6777,6778],{"class":217}," countries",[50,6780,214],{"class":196},[50,6782,4617],{"class":203},[50,6784,208],{"class":207},[50,6786,6726],{"class":203},[50,6788,6789],{"class":207},"[]>([]);\n",[50,6791,6792,6794,6797,6799,6801,6803,6806,6808,6810,6812,6814,6817,6819,6821],{"class":52,"line":59},[50,6793,952],{"class":196},[50,6795,6796],{"class":217}," byValue",[50,6798,214],{"class":196},[50,6800,4617],{"class":203},[50,6802,208],{"class":207},[50,6804,6805],{"class":203},"Map",[50,6807,208],{"class":207},[50,6809,281],{"class":217},[50,6811,284],{"class":207},[50,6813,6726],{"class":203},[50,6815,6816],{"class":207},">>(",[50,6818,5184],{"class":196},[50,6820,2282],{"class":203},[50,6822,6823],{"class":207},"());\n",[50,6825,6826,6828,6831,6833,6835,6837,6839,6841,6843,6845,6847,6850,6852,6854],{"class":52,"line":65},[50,6827,952],{"class":196},[50,6829,6830],{"class":217}," byDialDigits",[50,6832,214],{"class":196},[50,6834,4617],{"class":203},[50,6836,208],{"class":207},[50,6838,6805],{"class":203},[50,6840,208],{"class":207},[50,6842,281],{"class":217},[50,6844,284],{"class":207},[50,6846,6726],{"class":203},[50,6848,6849],{"class":207},"[]>>(",[50,6851,5184],{"class":196},[50,6853,2282],{"class":203},[50,6855,6823],{"class":207},[11,6857,6858,6859,6862,6863,6866,6867,6870,6871,6874,6875,4964,6878,181],{},"The array is for rendering, sorted alphabetically by country name via ",[15,6860,6861],{},"localeCompare"," so Åland Islands and Ålesund land where they should. ",[15,6864,6865],{},"byValue"," is an O(1) ISO2 lookup — ",[15,6868,6869],{},"byValue.get('EG')"," for Egypt. ",[15,6872,6873],{},"byDialDigits"," is the one I want to isolate: it's a bucketed list, ",[15,6876,6877],{},"Map\u003Cstring, CountryOption[]>",[15,6879,6880],{},"Map\u003Cstring, CountryOption>",[11,6882,6883,6884,6886,6887,6889,6890,6892],{},"Bucketing matters because dial codes are not unique. ",[15,6885,6758],{}," is shared by the US, Canada, and 20 Caribbean countries. A flat ",[15,6888,2234],{}," silently keeps the last one inserted and loses the rest. The day a Canadian user opens my phone input and the only ",[15,6891,6758],{}," option is the US, I'd never know — they'd just quietly pick one and move on.",[11,6894,6895],{},"Building the indexes is a one-pass loop at ingest, done once when countries load:",[41,6897,6899],{"className":187,"code":6898,"language":189,"meta":46,"style":46},"function rebuildIndexes(list: CountryOption[]) {\n  const valueMap = new Map\u003Cstring, CountryOption>();\n  const dialMap = new Map\u003Cstring, CountryOption[]>();\n  for (const item of list) {\n    valueMap.set(item.value, item);\n    const dial = item.raw_data.dial_digits;\n    if (dial) {\n      const bucket = dialMap.get(dial) ?? [];\n      bucket.push(item);\n      dialMap.set(dial, bucket);\n    }\n  }\n  byValue.value = valueMap;\n  byDialDigits.value = dialMap;\n}\n",[15,6900,6901,6920,6943,6967,6985,6995,7007,7014,7037,7048,7058,7062,7066,7076,7086],{"__ignoreMap":46},[50,6902,6903,6905,6908,6910,6913,6915,6917],{"class":52,"line":53},[50,6904,1895],{"class":196},[50,6906,6907],{"class":203}," rebuildIndexes",[50,6909,1073],{"class":207},[50,6911,6912],{"class":226},"list",[50,6914,230],{"class":196},[50,6916,6381],{"class":203},[50,6918,6919],{"class":207},"[]) {\n",[50,6921,6922,6924,6927,6929,6931,6933,6935,6937,6939,6941],{"class":52,"line":59},[50,6923,1439],{"class":196},[50,6925,6926],{"class":217}," valueMap",[50,6928,214],{"class":196},[50,6930,2279],{"class":196},[50,6932,2282],{"class":203},[50,6934,208],{"class":207},[50,6936,281],{"class":217},[50,6938,284],{"class":207},[50,6940,6726],{"class":203},[50,6942,2293],{"class":207},[50,6944,6945,6947,6950,6952,6954,6956,6958,6960,6962,6964],{"class":52,"line":65},[50,6946,1439],{"class":196},[50,6948,6949],{"class":217}," dialMap",[50,6951,214],{"class":196},[50,6953,2279],{"class":196},[50,6955,2282],{"class":203},[50,6957,208],{"class":207},[50,6959,281],{"class":217},[50,6961,284],{"class":207},[50,6963,6726],{"class":203},[50,6965,6966],{"class":207},"[]>();\n",[50,6968,6969,6972,6974,6976,6979,6982],{"class":52,"line":71},[50,6970,6971],{"class":196},"  for",[50,6973,420],{"class":207},[50,6975,952],{"class":196},[50,6977,6978],{"class":217}," item",[50,6980,6981],{"class":196}," of",[50,6983,6984],{"class":207}," list) {\n",[50,6986,6987,6990,6992],{"class":52,"line":77},[50,6988,6989],{"class":207},"    valueMap.",[50,6991,2316],{"class":203},[50,6993,6994],{"class":207},"(item.value, item);\n",[50,6996,6997,6999,7002,7004],{"class":52,"line":83},[50,6998,3224],{"class":196},[50,7000,7001],{"class":217}," dial",[50,7003,214],{"class":196},[50,7005,7006],{"class":207}," item.raw_data.dial_digits;\n",[50,7008,7009,7011],{"class":52,"line":89},[50,7010,1699],{"class":196},[50,7012,7013],{"class":207}," (dial) {\n",[50,7015,7016,7018,7021,7023,7026,7029,7032,7035],{"class":52,"line":95},[50,7017,6329],{"class":196},[50,7019,7020],{"class":217}," bucket",[50,7022,214],{"class":196},[50,7024,7025],{"class":207}," dialMap.",[50,7027,7028],{"class":203},"get",[50,7030,7031],{"class":207},"(dial) ",[50,7033,7034],{"class":196},"??",[50,7036,5242],{"class":207},[50,7038,7039,7042,7045],{"class":52,"line":101},[50,7040,7041],{"class":207},"      bucket.",[50,7043,7044],{"class":203},"push",[50,7046,7047],{"class":207},"(item);\n",[50,7049,7050,7053,7055],{"class":52,"line":107},[50,7051,7052],{"class":207},"      dialMap.",[50,7054,2316],{"class":203},[50,7056,7057],{"class":207},"(dial, bucket);\n",[50,7059,7060],{"class":52,"line":113},[50,7061,1745],{"class":207},[50,7063,7064],{"class":52,"line":120},[50,7065,110],{"class":207},[50,7067,7068,7071,7073],{"class":52,"line":126},[50,7069,7070],{"class":207},"  byValue.value ",[50,7072,639],{"class":196},[50,7074,7075],{"class":207}," valueMap;\n",[50,7077,7078,7081,7083],{"class":52,"line":132},[50,7079,7080],{"class":207},"  byDialDigits.value ",[50,7082,639],{"class":196},[50,7084,7085],{"class":207}," dialMap;\n",[50,7087,7088],{"class":52,"line":138},[50,7089,170],{"class":207},[11,7091,7092],{},"Search is the other half of the \"finding\" story, and it's where most implementations I've reviewed do too much work per keystroke. Common anti-pattern:",[41,7094,7096],{"className":187,"code":7095,"language":189,"meta":46,"style":46},"countries.value.filter((c) =>\n  c.raw_data.name.toLowerCase().includes(query.toLowerCase())\n);\n",[15,7097,7098,7114,7136],{"__ignoreMap":46},[50,7099,7100,7103,7105,7107,7110,7112],{"class":52,"line":53},[50,7101,7102],{"class":207},"countries.value.",[50,7104,2374],{"class":203},[50,7106,1676],{"class":207},[50,7108,7109],{"class":226},"c",[50,7111,440],{"class":207},[50,7113,2384],{"class":196},[50,7115,7116,7119,7122,7125,7128,7131,7133],{"class":52,"line":59},[50,7117,7118],{"class":207},"  c.raw_data.name.",[50,7120,7121],{"class":203},"toLowerCase",[50,7123,7124],{"class":207},"().",[50,7126,7127],{"class":203},"includes",[50,7129,7130],{"class":207},"(query.",[50,7132,7121],{"class":203},[50,7134,7135],{"class":207},"())\n",[50,7137,7138],{"class":52,"line":65},[50,7139,1971],{"class":207},[11,7141,7142,7145],{},[15,7143,7144],{},".toLowerCase()"," runs on 250 strings per keystroke. For a 250-country list it's fine. For a system with 50,000 rows, the pattern generalizes badly. I default to precomputing:",[41,7147,7149],{"className":187,"code":7148,"language":189,"meta":46,"style":46},"function normalizeSearchKey(input: string) {\n  return String(input ?? '')\n    .toLowerCase()\n    .replace(\u002F\\s+\u002Fg, ' ')\n    .trim()\n    .replace(\u002F[^\\da-z+ ]\u002Fg, '');\n}\n\n\u002F\u002F At ingest, per country:\nconst search_key = normalizeSearchKey(`${name} ${dial} ${iso2} ${dialDigits}`);\n",[15,7150,7151,7169,7185,7194,7224,7233,7262,7266,7270,7275],{"__ignoreMap":46},[50,7152,7153,7155,7158,7160,7163,7165,7167],{"class":52,"line":53},[50,7154,1895],{"class":196},[50,7156,7157],{"class":203}," normalizeSearchKey",[50,7159,1073],{"class":207},[50,7161,7162],{"class":226},"input",[50,7164,230],{"class":196},[50,7166,233],{"class":217},[50,7168,1718],{"class":207},[50,7170,7171,7173,7175,7178,7180,7183],{"class":52,"line":59},[50,7172,973],{"class":196},[50,7174,1961],{"class":203},[50,7176,7177],{"class":207},"(input ",[50,7179,7034],{"class":196},[50,7181,7182],{"class":311}," ''",[50,7184,2052],{"class":207},[50,7186,7187,7189,7191],{"class":52,"line":65},[50,7188,2011],{"class":207},[50,7190,7121],{"class":203},[50,7192,7193],{"class":207},"()\n",[50,7195,7196,7198,7201,7203,7206,7209,7212,7214,7217,7219,7222],{"class":52,"line":71},[50,7197,2011],{"class":207},[50,7199,7200],{"class":203},"replace",[50,7202,1073],{"class":207},[50,7204,7205],{"class":311},"\u002F",[50,7207,7208],{"class":217},"\\s",[50,7210,7211],{"class":196},"+",[50,7213,7205],{"class":311},[50,7215,7216],{"class":196},"g",[50,7218,284],{"class":207},[50,7220,7221],{"class":311},"' '",[50,7223,2052],{"class":207},[50,7225,7226,7228,7231],{"class":52,"line":77},[50,7227,2011],{"class":207},[50,7229,7230],{"class":203},"trim",[50,7232,7193],{"class":207},[50,7234,7235,7237,7239,7241,7243,7245,7248,7251,7253,7255,7257,7260],{"class":52,"line":83},[50,7236,2011],{"class":207},[50,7238,7200],{"class":203},[50,7240,1073],{"class":207},[50,7242,7205],{"class":311},[50,7244,2041],{"class":217},[50,7246,7247],{"class":196},"^",[50,7249,7250],{"class":217},"\\da-z+ ]",[50,7252,7205],{"class":311},[50,7254,7216],{"class":196},[50,7256,284],{"class":207},[50,7258,7259],{"class":311},"''",[50,7261,1971],{"class":207},[50,7263,7264],{"class":52,"line":89},[50,7265,170],{"class":207},[50,7267,7268],{"class":52,"line":95},[50,7269,117],{"emptyLinePlaceholder":116},[50,7271,7272],{"class":52,"line":101},[50,7273,7274],{"class":1853},"\u002F\u002F At ingest, per country:\n",[50,7276,7277,7279,7282,7284,7286,7288,7291,7294,7297,7300,7302,7305,7307,7310,7312],{"class":52,"line":107},[50,7278,952],{"class":196},[50,7280,7281],{"class":217}," search_key",[50,7283,214],{"class":196},[50,7285,7157],{"class":203},[50,7287,1073],{"class":207},[50,7289,7290],{"class":311},"`${",[50,7292,7293],{"class":207},"name",[50,7295,7296],{"class":311},"} ${",[50,7298,7299],{"class":207},"dial",[50,7301,7296],{"class":311},[50,7303,7304],{"class":207},"iso2",[50,7306,7296],{"class":311},[50,7308,7309],{"class":207},"dialDigits",[50,7311,2049],{"class":311},[50,7313,1971],{"class":207},[11,7315,7316,7317,7320,7321,7324],{},"Each country carries a precomputed ",[15,7318,7319],{},"search_key"," that combines its name, dial code, ISO2, and dial digits, normalized: lowercased, whitespace-collapsed, non-alphanumeric stripped. At query time, the user's keyword goes through the ",[504,7322,7323],{},"same"," normalizer, and search is a straight substring match with an early break once we've got 50 hits:",[41,7326,7328],{"className":187,"code":7327,"language":189,"meta":46,"style":46},"function searchCountries(keyword: string, limit = 50) {\n  const q = normalizeSearchKey(keyword);\n  if (!q) return countries.value.slice(0, limit);\n\n  const res: CountryOption[] = [];\n  for (const item of countries.value) {\n    if (item.search_key.includes(q)) {\n      res.push(item);\n      if (res.length >= limit) break;\n    }\n  }\n  return res;\n}\n",[15,7329,7330,7358,7372,7397,7401,7418,7433,7445,7454,7473,7477,7481,7488],{"__ignoreMap":46},[50,7331,7332,7334,7337,7339,7342,7344,7346,7348,7351,7353,7356],{"class":52,"line":53},[50,7333,1895],{"class":196},[50,7335,7336],{"class":203}," searchCountries",[50,7338,1073],{"class":207},[50,7340,7341],{"class":226},"keyword",[50,7343,230],{"class":196},[50,7345,233],{"class":217},[50,7347,284],{"class":207},[50,7349,7350],{"class":226},"limit",[50,7352,214],{"class":196},[50,7354,7355],{"class":217}," 50",[50,7357,1718],{"class":207},[50,7359,7360,7362,7365,7367,7369],{"class":52,"line":59},[50,7361,1439],{"class":196},[50,7363,7364],{"class":217}," q",[50,7366,214],{"class":196},[50,7368,7157],{"class":203},[50,7370,7371],{"class":207},"(keyword);\n",[50,7373,7374,7376,7378,7380,7383,7385,7387,7390,7392,7394],{"class":52,"line":65},[50,7375,1270],{"class":196},[50,7377,420],{"class":207},[50,7379,1275],{"class":196},[50,7381,7382],{"class":207},"q) ",[50,7384,1281],{"class":196},[50,7386,6284],{"class":207},[50,7388,7389],{"class":203},"slice",[50,7391,1073],{"class":207},[50,7393,5065],{"class":217},[50,7395,7396],{"class":207},", limit);\n",[50,7398,7399],{"class":52,"line":71},[50,7400,117],{"emptyLinePlaceholder":116},[50,7402,7403,7405,7407,7409,7411,7414,7416],{"class":52,"line":77},[50,7404,1439],{"class":196},[50,7406,6208],{"class":217},[50,7408,230],{"class":196},[50,7410,6381],{"class":203},[50,7412,7413],{"class":207},"[] ",[50,7415,639],{"class":196},[50,7417,5242],{"class":207},[50,7419,7420,7422,7424,7426,7428,7430],{"class":52,"line":83},[50,7421,6971],{"class":196},[50,7423,420],{"class":207},[50,7425,952],{"class":196},[50,7427,6978],{"class":217},[50,7429,6981],{"class":196},[50,7431,7432],{"class":207}," countries.value) {\n",[50,7434,7435,7437,7440,7442],{"class":52,"line":89},[50,7436,1699],{"class":196},[50,7438,7439],{"class":207}," (item.search_key.",[50,7441,7127],{"class":203},[50,7443,7444],{"class":207},"(q)) {\n",[50,7446,7447,7450,7452],{"class":52,"line":95},[50,7448,7449],{"class":207},"      res.",[50,7451,7044],{"class":203},[50,7453,7047],{"class":207},[50,7455,7456,7458,7461,7463,7465,7468,7471],{"class":52,"line":101},[50,7457,6352],{"class":196},[50,7459,7460],{"class":207}," (res.",[50,7462,2413],{"class":217},[50,7464,4266],{"class":196},[50,7466,7467],{"class":207}," limit) ",[50,7469,7470],{"class":196},"break",[50,7472,325],{"class":207},[50,7474,7475],{"class":52,"line":107},[50,7476,1745],{"class":207},[50,7478,7479],{"class":52,"line":113},[50,7480,110],{"class":207},[50,7482,7483,7485],{"class":52,"line":120},[50,7484,973],{"class":196},[50,7486,7487],{"class":207}," res;\n",[50,7489,7490],{"class":52,"line":126},[50,7491,170],{"class":207},[11,7493,7494,7495,7497,7498,7501],{},"Symmetrical normalization on both sides is the invariant that makes the whole thing work. Without it, a trailing space or a ",[15,7496,1275],{}," in the input silently wipes out all matches. The early break trims ",[15,7499,7500],{},"\"a\""," queries from \"iterate all 250\" to \"stop at 50,\" which is the difference between a fast dropdown and a janky one on older devices.",[11,7503,7504,7505,7508],{},"There's one place in the country-select component where this rigor breaks down. Resolving the currently-selected country object uses ",[15,7506,7507],{},".find()"," rather than the dial-digits index:",[41,7510,7512],{"className":187,"code":7511,"language":189,"meta":46,"style":46},"const selectedCountryObject = computed(\n  () => countries.value.find((c) => c.raw_data.dial_digits === selectedCountry.value) ?? null\n);\n",[15,7513,7514,7527,7559],{"__ignoreMap":46},[50,7515,7516,7518,7521,7523,7525],{"class":52,"line":53},[50,7517,952],{"class":196},[50,7519,7520],{"class":217}," selectedCountryObject",[50,7522,214],{"class":196},[50,7524,960],{"class":203},[50,7526,979],{"class":207},[50,7528,7529,7531,7533,7535,7538,7540,7542,7544,7546,7549,7551,7554,7556],{"class":52,"line":59},[50,7530,3462],{"class":207},[50,7532,362],{"class":196},[50,7534,6284],{"class":207},[50,7536,7537],{"class":203},"find",[50,7539,1676],{"class":207},[50,7541,7109],{"class":226},[50,7543,440],{"class":207},[50,7545,362],{"class":196},[50,7547,7548],{"class":207}," c.raw_data.dial_digits ",[50,7550,1297],{"class":196},[50,7552,7553],{"class":207}," selectedCountry.value) ",[50,7555,7034],{"class":196},[50,7557,7558],{"class":217}," null\n",[50,7560,7561],{"class":52,"line":65},[50,7562,1971],{"class":207},[11,7564,7565,7567,7568,7571],{},[15,7566,7507],{}," is O(n) when ",[15,7569,7570],{},"byDialDigits.get()"," would be O(1). The reason is historical — I wrote this part before I built the index, and once the index existed I didn't go back. It's ~250 items; the dropdown is already O(n) to render; the extra lookup isn't the performance story. I'm leaving it in as an honest reminder that consistent discipline is aspirational, not permanent.",[30,7573,7575,7576,7578],{"id":7574},"placeholders-from-libphonenumber-js-example-numbers","Placeholders from ",[15,7577,6181],{}," example numbers",[11,7580,7581,7582,7585],{},"Most phone inputs I've used do one of two things with placeholders: hardcode a made-up number like ",[15,7583,7584],{},"\"+1 (555) 123-4567\""," that never changes, or show nothing. Both give up some information the user could use.",[11,7587,7588,7590,7591,7593],{},[15,7589,5979],{}," uses ",[15,7592,6181],{},"'s bundled example database:",[41,7595,7597],{"className":187,"code":7596,"language":189,"meta":46,"style":46},"import { getExampleNumber, isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';\nimport examples from 'libphonenumber-js\u002Fexamples.mobile.json';\nconst EX = examples as unknown as Examples;\n\nconst example = getExampleNumber(iso2 as CountryCode, EX);\nconst exampleNational = example?.formatNational?.() ?? '';  \u002F\u002F \"010 6610 5963\"\nconst exampleE164 = example?.format?.('E.164') ?? '';        \u002F\u002F \"+201066105963\"\n",[15,7598,7599,7615,7629,7651,7655,7682,7710],{"__ignoreMap":46},[50,7600,7601,7604,7607,7610,7613],{"class":52,"line":53},[50,7602,7603],{"class":196},"import",[50,7605,7606],{"class":207}," { getExampleNumber, isValidPhoneNumber, parsePhoneNumberFromString } ",[50,7608,7609],{"class":196},"from",[50,7611,7612],{"class":311}," 'libphonenumber-js'",[50,7614,325],{"class":207},[50,7616,7617,7619,7622,7624,7627],{"class":52,"line":59},[50,7618,7603],{"class":196},[50,7620,7621],{"class":207}," examples ",[50,7623,7609],{"class":196},[50,7625,7626],{"class":311}," 'libphonenumber-js\u002Fexamples.mobile.json'",[50,7628,325],{"class":207},[50,7630,7631,7633,7636,7638,7640,7642,7644,7646,7649],{"class":52,"line":65},[50,7632,952],{"class":196},[50,7634,7635],{"class":217}," EX",[50,7637,214],{"class":196},[50,7639,7621],{"class":207},[50,7641,1537],{"class":196},[50,7643,218],{"class":217},[50,7645,2141],{"class":196},[50,7647,7648],{"class":203}," Examples",[50,7650,325],{"class":207},[50,7652,7653],{"class":52,"line":71},[50,7654,117],{"emptyLinePlaceholder":116},[50,7656,7657,7659,7662,7664,7667,7670,7672,7675,7677,7680],{"class":52,"line":77},[50,7658,952],{"class":196},[50,7660,7661],{"class":217}," example",[50,7663,214],{"class":196},[50,7665,7666],{"class":203}," getExampleNumber",[50,7668,7669],{"class":207},"(iso2 ",[50,7671,1537],{"class":196},[50,7673,7674],{"class":203}," CountryCode",[50,7676,284],{"class":207},[50,7678,7679],{"class":217},"EX",[50,7681,1971],{"class":207},[50,7683,7684,7686,7689,7691,7694,7697,7700,7702,7704,7707],{"class":52,"line":83},[50,7685,952],{"class":196},[50,7687,7688],{"class":217}," exampleNational",[50,7690,214],{"class":196},[50,7692,7693],{"class":207}," example?.",[50,7695,7696],{"class":203},"formatNational",[50,7698,7699],{"class":207},"?.() ",[50,7701,7034],{"class":196},[50,7703,7182],{"class":311},[50,7705,7706],{"class":207},";  ",[50,7708,7709],{"class":1853},"\u002F\u002F \"010 6610 5963\"\n",[50,7711,7712,7714,7717,7719,7721,7724,7727,7730,7732,7734,7736,7739],{"class":52,"line":89},[50,7713,952],{"class":196},[50,7715,7716],{"class":217}," exampleE164",[50,7718,214],{"class":196},[50,7720,7693],{"class":207},[50,7722,7723],{"class":203},"format",[50,7725,7726],{"class":207},"?.(",[50,7728,7729],{"class":311},"'E.164'",[50,7731,440],{"class":207},[50,7733,7034],{"class":196},[50,7735,7182],{"class":311},[50,7737,7738],{"class":207},";        ",[50,7740,7741],{"class":1853},"\u002F\u002F \"+201066105963\"\n",[11,7743,7744,7745,7748],{},"For any ISO2, ",[15,7746,7747],{},"getExampleNumber"," returns a real, valid, currently-issued mobile number for that country. The national format becomes the placeholder; the E.164 format is used during validation to reconstruct the full number.",[11,7750,7751],{},"The template pulls the placeholder out with a bit of string surgery:",[41,7753,7755],{"className":187,"code":7754,"language":189,"meta":46,"style":46},"const internalHelperText = computed(() => {\n  const rq = validation.value.required;\n  const example = rq?.example_e164;\n  const dial = rq?.dial_code;\n  return (example && dial ? example.split(dial)?.[1] : '') || props.placeholder || 'رقم الجوال';\n});\n",[15,7756,7757,7774,7786,7797,7808,7853],{"__ignoreMap":46},[50,7758,7759,7761,7764,7766,7768,7770,7772],{"class":52,"line":53},[50,7760,952],{"class":196},[50,7762,7763],{"class":217}," internalHelperText",[50,7765,214],{"class":196},[50,7767,960],{"class":203},[50,7769,963],{"class":207},[50,7771,362],{"class":196},[50,7773,968],{"class":207},[50,7775,7776,7778,7781,7783],{"class":52,"line":59},[50,7777,1439],{"class":196},[50,7779,7780],{"class":217}," rq",[50,7782,214],{"class":196},[50,7784,7785],{"class":207}," validation.value.required;\n",[50,7787,7788,7790,7792,7794],{"class":52,"line":65},[50,7789,1439],{"class":196},[50,7791,7661],{"class":217},[50,7793,214],{"class":196},[50,7795,7796],{"class":207}," rq?.example_e164;\n",[50,7798,7799,7801,7803,7805],{"class":52,"line":71},[50,7800,1439],{"class":196},[50,7802,7001],{"class":217},[50,7804,214],{"class":196},[50,7806,7807],{"class":207}," rq?.dial_code;\n",[50,7809,7810,7812,7815,7817,7820,7822,7825,7827,7830,7832,7835,7837,7839,7841,7843,7846,7848,7851],{"class":52,"line":77},[50,7811,973],{"class":196},[50,7813,7814],{"class":207}," (example ",[50,7816,1705],{"class":196},[50,7818,7819],{"class":207}," dial ",[50,7821,987],{"class":196},[50,7823,7824],{"class":207}," example.",[50,7826,1662],{"class":203},[50,7828,7829],{"class":207},"(dial)?.[",[50,7831,5270],{"class":217},[50,7833,7834],{"class":207},"] ",[50,7836,230],{"class":196},[50,7838,7182],{"class":311},[50,7840,440],{"class":207},[50,7842,4757],{"class":196},[50,7844,7845],{"class":207}," props.placeholder ",[50,7847,4757],{"class":196},[50,7849,7850],{"class":311}," 'رقم الجوال'",[50,7852,325],{"class":207},[50,7854,7855],{"class":52,"line":83},[50,7856,1153],{"class":207},[11,7858,7859,7862,7863,7866],{},[15,7860,7861],{},"\"+201066105963\".split(\"+20\")[1]"," gives ",[15,7864,7865],{},"\"1066105963\""," — the example minus the country code, which is already displayed in the dropdown next to the input. When the country changes, the placeholder changes. Not a generic \"Enter phone number\" — a real Egyptian number if Egypt is selected, a real Saudi number if it's Saudi Arabia.",[11,7868,7869],{},"The length bounds come from the same example, not from a hardcoded table:",[41,7871,7873],{"className":187,"code":7872,"language":189,"meta":46,"style":46},"function inferLengthFromExample(national: string) {\n  const d = toDigits(national);\n  if (!d) return { min: null, max: null };\n  const n = d.length;\n  return { min: Math.max(4, n - 2), max: n + 2 };\n}\n",[15,7874,7875,7893,7908,7933,7949,7982],{"__ignoreMap":46},[50,7876,7877,7879,7882,7884,7887,7889,7891],{"class":52,"line":53},[50,7878,1895],{"class":196},[50,7880,7881],{"class":203}," inferLengthFromExample",[50,7883,1073],{"class":207},[50,7885,7886],{"class":226},"national",[50,7888,230],{"class":196},[50,7890,233],{"class":217},[50,7892,1718],{"class":207},[50,7894,7895,7897,7900,7902,7905],{"class":52,"line":59},[50,7896,1439],{"class":196},[50,7898,7899],{"class":217}," d",[50,7901,214],{"class":196},[50,7903,7904],{"class":203}," toDigits",[50,7906,7907],{"class":207},"(national);\n",[50,7909,7910,7912,7914,7916,7919,7921,7924,7926,7929,7931],{"class":52,"line":65},[50,7911,1270],{"class":196},[50,7913,420],{"class":207},[50,7915,1275],{"class":196},[50,7917,7918],{"class":207},"d) ",[50,7920,1281],{"class":196},[50,7922,7923],{"class":207}," { min: ",[50,7925,4673],{"class":217},[50,7927,7928],{"class":207},", max: ",[50,7930,4673],{"class":217},[50,7932,2876],{"class":207},[50,7934,7935,7937,7940,7942,7945,7947],{"class":52,"line":71},[50,7936,1439],{"class":196},[50,7938,7939],{"class":217}," n",[50,7941,214],{"class":196},[50,7943,7944],{"class":207}," d.",[50,7946,2413],{"class":217},[50,7948,325],{"class":207},[50,7950,7951,7953,7956,7959,7961,7964,7967,7970,7973,7976,7978,7980],{"class":52,"line":77},[50,7952,973],{"class":196},[50,7954,7955],{"class":207}," { min: Math.",[50,7957,7958],{"class":203},"max",[50,7960,1073],{"class":207},[50,7962,7963],{"class":217},"4",[50,7965,7966],{"class":207},", n ",[50,7968,7969],{"class":196},"-",[50,7971,7972],{"class":217}," 2",[50,7974,7975],{"class":207},"), max: n ",[50,7977,7211],{"class":196},[50,7979,7972],{"class":217},[50,7981,2876],{"class":207},[50,7983,7984],{"class":52,"line":83},[50,7985,170],{"class":207},[11,7987,7988,7989,7991,7992,7994,7995,7998,7999,8001],{},"The window is example length ± 2, floored at 4. This is deliberately loose because it's a ",[504,7990,41],{},"-check — the real validity test is ",[15,7993,6181],{},"'s ",[15,7996,7997],{},"isValidPhoneNumber",", which is expensive-ish. The pre-check cheaply rejects \"3 digits and stopped\" and \"15 digits, might be an IMEI,\" and lets plausible input through to the real validator. The day ",[15,8000,6181],{}," updates its examples (which it does every few months), my placeholders and lengths update with it. I don't own a table of digit counts per country; that table doesn't exist in my repo.",[30,8003,8005,8008],{"id":8004},"oninput-the-handler-that-rewrites-the-input",[15,8006,8007],{},"onInput",": the handler that rewrites the input",[11,8010,892,8011,8014],{},[15,8012,8013],{},"\u003Cinput>"," element has one of the strangest handlers in the file:",[41,8016,8018],{"className":187,"code":8017,"language":189,"meta":46,"style":46},"function onInput(event: Event) {\n  const input = event.target as HTMLInputElement;\n  const next = input.value.replace(NON_DIGITS_RE, '');\n  if (input.value !== next) input.value = next;\n  phoneModelValue.value = next;\n}\n",[15,8019,8020,8039,8058,8083,8100,8109],{"__ignoreMap":46},[50,8021,8022,8024,8027,8029,8032,8034,8037],{"class":52,"line":53},[50,8023,1895],{"class":196},[50,8025,8026],{"class":203}," onInput",[50,8028,1073],{"class":207},[50,8030,8031],{"class":226},"event",[50,8033,230],{"class":196},[50,8035,8036],{"class":203}," Event",[50,8038,1718],{"class":207},[50,8040,8041,8043,8046,8048,8051,8053,8056],{"class":52,"line":59},[50,8042,1439],{"class":196},[50,8044,8045],{"class":217}," input",[50,8047,214],{"class":196},[50,8049,8050],{"class":207}," event.target ",[50,8052,1537],{"class":196},[50,8054,8055],{"class":203}," HTMLInputElement",[50,8057,325],{"class":207},[50,8059,8060,8062,8065,8067,8070,8072,8074,8077,8079,8081],{"class":52,"line":65},[50,8061,1439],{"class":196},[50,8063,8064],{"class":217}," next",[50,8066,214],{"class":196},[50,8068,8069],{"class":207}," input.value.",[50,8071,7200],{"class":203},[50,8073,1073],{"class":207},[50,8075,8076],{"class":217},"NON_DIGITS_RE",[50,8078,284],{"class":207},[50,8080,7259],{"class":311},[50,8082,1971],{"class":207},[50,8084,8085,8087,8090,8092,8095,8097],{"class":52,"line":71},[50,8086,1270],{"class":196},[50,8088,8089],{"class":207}," (input.value ",[50,8091,1949],{"class":196},[50,8093,8094],{"class":207}," next) input.value ",[50,8096,639],{"class":196},[50,8098,8099],{"class":207}," next;\n",[50,8101,8102,8105,8107],{"class":52,"line":77},[50,8103,8104],{"class":207},"  phoneModelValue.value ",[50,8106,639],{"class":196},[50,8108,8099],{"class":207},[50,8110,8111],{"class":52,"line":83},[50,8112,170],{"class":207},[11,8114,8115,8116,8119],{},"Why write ",[15,8117,8118],{},"input.value = next"," directly when Vue's v-model would update the DOM on the next tick? Because \"next tick\" is the problem.",[11,8121,8122,8123,8125,8126,8128,8129,8131],{},"Picture the user typing ",[15,8124,7211],{}," into a digits-only input. Vue's reactivity sees the new value, strips the ",[15,8127,7211],{},", and writes the empty string back to ",[15,8130,3427],{}," — but the browser has already placed the cursor after the typed character. You end up with the cursor at position 1 in an empty string, which is nonsense, and the next character the user types gets placed past the end. The input works by accident, because the browser silently clamps the out-of-bounds cursor.",[11,8133,8134,8135,8138,8139,8141,8142,8145],{},"Paste ",[15,8136,8137],{},"\"123+\""," and it's louder. Vue strips the ",[15,8140,7211],{},", value becomes ",[15,8143,8144],{},"\"123\"",", cursor was at position 4, browser clamps to position 3. The user's next character lands at the end. Sometimes correct, sometimes not, depending on exactly when the clamp fires relative to the next keystroke.",[11,8147,8148,8149,8151,8152,8155],{},"Writing ",[15,8150,8118],{}," synchronously — inside the same event tick, before v-model gets involved — avoids the whole race. The guard (",[15,8153,8154],{},"if (input.value !== next)",") exists so we don't nudge the cursor when nothing changed. This is the detail that never makes it into the \"Vue v-model best practices\" blog posts. It's also the detail every company I've worked at has hit at least once.",[11,8157,8158],{},"There's a related trick in the setter bridge:",[41,8160,8162],{"className":187,"code":8161,"language":189,"meta":46,"style":46},"const mobileModel = computed\u003Cstring>({\n  get: () => phoneModelValue.value ?? '',\n  set: (v) => {\n    phoneModelValue.value = dropLeadingZeros(v);\n  },\n});\n",[15,8163,8164,8182,8201,8217,8230,8234],{"__ignoreMap":46},[50,8165,8166,8168,8171,8173,8175,8177,8179],{"class":52,"line":53},[50,8167,952],{"class":196},[50,8169,8170],{"class":217}," mobileModel",[50,8172,214],{"class":196},[50,8174,960],{"class":203},[50,8176,208],{"class":207},[50,8178,281],{"class":217},[50,8180,8181],{"class":207},">({\n",[50,8183,8184,8187,8190,8192,8195,8197,8199],{"class":52,"line":59},[50,8185,8186],{"class":203},"  get",[50,8188,8189],{"class":207},": () ",[50,8191,362],{"class":196},[50,8193,8194],{"class":207}," phoneModelValue.value ",[50,8196,7034],{"class":196},[50,8198,7182],{"class":311},[50,8200,999],{"class":207},[50,8202,8203,8206,8208,8211,8213,8215],{"class":52,"line":65},[50,8204,8205],{"class":203},"  set",[50,8207,2675],{"class":207},[50,8209,8210],{"class":226},"v",[50,8212,440],{"class":207},[50,8214,362],{"class":196},[50,8216,968],{"class":207},[50,8218,8219,8222,8224,8227],{"class":52,"line":71},[50,8220,8221],{"class":207},"    phoneModelValue.value ",[50,8223,639],{"class":196},[50,8225,8226],{"class":203}," dropLeadingZeros",[50,8228,8229],{"class":207},"(v);\n",[50,8231,8232],{"class":52,"line":77},[50,8233,3646],{"class":207},[50,8235,8236],{"class":52,"line":83},[50,8237,1153],{"class":207},[11,8239,8240,8241,8244,8245,8247,8248,8250],{},"Egyptian users type ",[15,8242,8243],{},"01066105963"," — the ",[15,8246,5065],{}," is a national-dialing prefix, not part of the number. Saudi users do the same. E.164 drops that zero. The setter strips it on assignment, so the stored value is always in a clean E.164-ready form regardless of what the user typed. Some countries keep leading zeros in E.164; ",[15,8249,6181],{},"'s validator catches any edge case where this is wrong. For the 200+ countries where it applies, the setter does the transformation for free.",[30,8252,8254],{"id":8253},"seven-validation-reasons-two-passes","Seven validation reasons, two passes",[11,8256,8257,8258,8261,8262,230],{},"The lazy version of phone validation returns ",[15,8259,8260],{},"{ valid: boolean; message?: string }",". The honest version — the one consumers can actually build a good UX on — says ",[504,8263,8264],{},"why",[41,8266,8268],{"className":187,"code":8267,"language":189,"meta":46,"style":46},"export type PhoneValidationReason =\n  | 'missing_country'\n  | 'country_not_supported'\n  | 'phone_has_non_digits'\n  | 'too_short'\n  | 'too_long'\n  | 'invalid_phone'\n  | 'parse_failed';\n",[15,8269,8270,8282,8289,8296,8303,8310,8317,8324],{"__ignoreMap":46},[50,8271,8272,8274,8276,8279],{"class":52,"line":53},[50,8273,197],{"class":196},[50,8275,2849],{"class":196},[50,8277,8278],{"class":203}," PhoneValidationReason",[50,8280,8281],{"class":196}," =\n",[50,8283,8284,8286],{"class":52,"line":59},[50,8285,4489],{"class":196},[50,8287,8288],{"class":311}," 'missing_country'\n",[50,8290,8291,8293],{"class":52,"line":65},[50,8292,4489],{"class":196},[50,8294,8295],{"class":311}," 'country_not_supported'\n",[50,8297,8298,8300],{"class":52,"line":71},[50,8299,4489],{"class":196},[50,8301,8302],{"class":311}," 'phone_has_non_digits'\n",[50,8304,8305,8307],{"class":52,"line":77},[50,8306,4489],{"class":196},[50,8308,8309],{"class":311}," 'too_short'\n",[50,8311,8312,8314],{"class":52,"line":83},[50,8313,4489],{"class":196},[50,8315,8316],{"class":311}," 'too_long'\n",[50,8318,8319,8321],{"class":52,"line":89},[50,8320,4489],{"class":196},[50,8322,8323],{"class":311}," 'invalid_phone'\n",[50,8325,8326,8328,8331],{"class":52,"line":95},[50,8327,4489],{"class":196},[50,8329,8330],{"class":311}," 'parse_failed'",[50,8332,325],{"class":207},[11,8334,8335,8336,8339,8340,8343,8344,8347,8348,8351],{},"Seven specific reasons. Not one \"invalid\" bucket. The distinctions matter in the UI. \"Too short\" is a ",[504,8337,8338],{},"progress"," signal — the user is still typing, don't yell at them. \"Country not supported\" is a ",[504,8341,8342],{},"setup"," signal — they need to pick a different country. \"Invalid phone\" with ",[15,8345,8346],{},"details.possible: true"," is a ",[504,8349,8350],{},"hint"," signal — the number could be valid somewhere else, and you might want to suggest \"did you mean a different country?\"",[11,8353,8354],{},"The full result object is richer than the enum:",[41,8356,8358],{"className":187,"code":8357,"language":189,"meta":46,"style":46},"export type PhoneValidationResult = {\n  ok: boolean;\n  reason: PhoneValidationReason | null;\n  country: { iso2: string; dial_code: string } | null;\n  phone: { raw: string | null; digits: string };\n  full_phone: string | null;\n  required: PhoneRequiredInfo | null;\n  details?: Record\u003Cstring, unknown>;\n};\n",[15,8359,8360,8373,8384,8399,8432,8463,8478,8494,8513],{"__ignoreMap":46},[50,8361,8362,8364,8366,8369,8371],{"class":52,"line":53},[50,8363,197],{"class":196},[50,8365,2849],{"class":196},[50,8367,8368],{"class":203}," PhoneValidationResult",[50,8370,214],{"class":196},[50,8372,968],{"class":207},[50,8374,8375,8378,8380,8382],{"class":52,"line":59},[50,8376,8377],{"class":226},"  ok",[50,8379,230],{"class":196},[50,8381,335],{"class":217},[50,8383,325],{"class":207},[50,8385,8386,8389,8391,8393,8395,8397],{"class":52,"line":65},[50,8387,8388],{"class":226},"  reason",[50,8390,230],{"class":196},[50,8392,8278],{"class":203},[50,8394,236],{"class":196},[50,8396,4667],{"class":217},[50,8398,325],{"class":207},[50,8400,8401,8404,8406,8408,8410,8412,8414,8416,8419,8421,8423,8426,8428,8430],{"class":52,"line":71},[50,8402,8403],{"class":226},"  country",[50,8405,230],{"class":196},[50,8407,2856],{"class":207},[50,8409,7304],{"class":226},[50,8411,230],{"class":196},[50,8413,233],{"class":217},[50,8415,2866],{"class":207},[50,8417,8418],{"class":226},"dial_code",[50,8420,230],{"class":196},[50,8422,233],{"class":217},[50,8424,8425],{"class":207}," } ",[50,8427,293],{"class":196},[50,8429,4667],{"class":217},[50,8431,325],{"class":207},[50,8433,8434,8437,8439,8441,8444,8446,8448,8450,8452,8454,8457,8459,8461],{"class":52,"line":77},[50,8435,8436],{"class":226},"  phone",[50,8438,230],{"class":196},[50,8440,2856],{"class":207},[50,8442,8443],{"class":226},"raw",[50,8445,230],{"class":196},[50,8447,233],{"class":217},[50,8449,236],{"class":196},[50,8451,4667],{"class":217},[50,8453,2866],{"class":207},[50,8455,8456],{"class":226},"digits",[50,8458,230],{"class":196},[50,8460,233],{"class":217},[50,8462,2876],{"class":207},[50,8464,8465,8468,8470,8472,8474,8476],{"class":52,"line":83},[50,8466,8467],{"class":226},"  full_phone",[50,8469,230],{"class":196},[50,8471,233],{"class":217},[50,8473,236],{"class":196},[50,8475,4667],{"class":217},[50,8477,325],{"class":207},[50,8479,8480,8483,8485,8488,8490,8492],{"class":52,"line":89},[50,8481,8482],{"class":226},"  required",[50,8484,230],{"class":196},[50,8486,8487],{"class":203}," PhoneRequiredInfo",[50,8489,236],{"class":196},[50,8491,4667],{"class":217},[50,8493,325],{"class":207},[50,8495,8496,8499,8501,8503,8505,8507,8509,8511],{"class":52,"line":95},[50,8497,8498],{"class":226},"  details",[50,8500,273],{"class":196},[50,8502,276],{"class":203},[50,8504,208],{"class":207},[50,8506,281],{"class":217},[50,8508,284],{"class":207},[50,8510,287],{"class":217},[50,8512,246],{"class":207},[50,8514,8515],{"class":52,"line":101},[50,8516,1319],{"class":207},[11,8518,8519,8520,8523,8524,8527,8528,8531,8532,8534,8535,8537,8538,181],{},"Three representations of the phone number live together: ",[15,8521,8522],{},"phone.raw"," is what the user typed, ",[15,8525,8526],{},"phone.digits"," is the digits-only sanitized form, ",[15,8529,8530],{},"full_phone"," is the E.164 string. That's three different representations carried together because three different consumers want different ones — the UI wants ",[15,8533,8443],{}," to re-render, the backend wants ",[15,8536,8530],{}," as E.164, an analytics system might want ",[15,8539,8456],{},[11,8541,8542],{},"The validator itself runs two passes:",[41,8544,8546],{"className":187,"code":8545,"language":189,"meta":46,"style":46},"const ok = isValidPhoneNumber(full, iso2 as CountryCode);\n\nif (!ok) {\n  const parsed = parsePhoneNumberFromString(full, iso2 as CountryCode);\n  return {\n    ok: false,\n    reason: 'invalid_phone',\n    country: { iso2: required.iso2, dial_code: required.dial_code },\n    phone: { raw, digits },\n    full_phone: parsed?.number ?? null,\n    required,\n    details: {\n      type: parsed?.getType?.() ?? null,\n      possible: parsed?.isPossible?.() ?? null,\n      country: parsed?.country ?? null,\n    },\n  };\n}\n",[15,8547,8548,8569,8573,8584,8603,8609,8618,8628,8633,8638,8649,8654,8659,8675,8691,8702,8706,8711],{"__ignoreMap":46},[50,8549,8550,8552,8555,8557,8560,8563,8565,8567],{"class":52,"line":53},[50,8551,952],{"class":196},[50,8553,8554],{"class":217}," ok",[50,8556,214],{"class":196},[50,8558,8559],{"class":203}," isValidPhoneNumber",[50,8561,8562],{"class":207},"(full, iso2 ",[50,8564,1537],{"class":196},[50,8566,7674],{"class":203},[50,8568,1971],{"class":207},[50,8570,8571],{"class":52,"line":59},[50,8572,117],{"emptyLinePlaceholder":116},[50,8574,8575,8577,8579,8581],{"class":52,"line":65},[50,8576,2488],{"class":196},[50,8578,420],{"class":207},[50,8580,1275],{"class":196},[50,8582,8583],{"class":207},"ok) {\n",[50,8585,8586,8588,8590,8592,8595,8597,8599,8601],{"class":52,"line":71},[50,8587,1439],{"class":196},[50,8589,6363],{"class":217},[50,8591,214],{"class":196},[50,8593,8594],{"class":203}," parsePhoneNumberFromString",[50,8596,8562],{"class":207},[50,8598,1537],{"class":196},[50,8600,7674],{"class":203},[50,8602,1971],{"class":207},[50,8604,8605,8607],{"class":52,"line":77},[50,8606,973],{"class":196},[50,8608,968],{"class":207},[50,8610,8611,8614,8616],{"class":52,"line":83},[50,8612,8613],{"class":207},"    ok: ",[50,8615,4639],{"class":217},[50,8617,999],{"class":207},[50,8619,8620,8623,8626],{"class":52,"line":89},[50,8621,8622],{"class":207},"    reason: ",[50,8624,8625],{"class":311},"'invalid_phone'",[50,8627,999],{"class":207},[50,8629,8630],{"class":52,"line":95},[50,8631,8632],{"class":207},"    country: { iso2: required.iso2, dial_code: required.dial_code },\n",[50,8634,8635],{"class":52,"line":101},[50,8636,8637],{"class":207},"    phone: { raw, digits },\n",[50,8639,8640,8643,8645,8647],{"class":52,"line":107},[50,8641,8642],{"class":207},"    full_phone: parsed?.number ",[50,8644,7034],{"class":196},[50,8646,4667],{"class":217},[50,8648,999],{"class":207},[50,8650,8651],{"class":52,"line":113},[50,8652,8653],{"class":207},"    required,\n",[50,8655,8656],{"class":52,"line":120},[50,8657,8658],{"class":207},"    details: {\n",[50,8660,8661,8664,8667,8669,8671,8673],{"class":52,"line":126},[50,8662,8663],{"class":207},"      type: parsed?.",[50,8665,8666],{"class":203},"getType",[50,8668,7699],{"class":207},[50,8670,7034],{"class":196},[50,8672,4667],{"class":217},[50,8674,999],{"class":207},[50,8676,8677,8680,8683,8685,8687,8689],{"class":52,"line":132},[50,8678,8679],{"class":207},"      possible: parsed?.",[50,8681,8682],{"class":203},"isPossible",[50,8684,7699],{"class":207},[50,8686,7034],{"class":196},[50,8688,4667],{"class":217},[50,8690,999],{"class":207},[50,8692,8693,8696,8698,8700],{"class":52,"line":138},[50,8694,8695],{"class":207},"      country: parsed?.country ",[50,8697,7034],{"class":196},[50,8699,4667],{"class":217},[50,8701,999],{"class":207},[50,8703,8704],{"class":52,"line":144},[50,8705,1065],{"class":207},[50,8707,8708],{"class":52,"line":150},[50,8709,8710],{"class":207},"  };\n",[50,8712,8713],{"class":52,"line":156},[50,8714,170],{"class":207},[11,8716,8717,8718,8720,8721,8724,8725,8728,8729,8732],{},"First pass is the fast boolean check. If it fails, the second pass runs the full parser to extract structured metadata: the number's ",[504,8719,1823],{}," (mobile, landline, toll-free), whether it's ",[504,8722,8723],{},"possible"," (right length for some country, even if not this one), and which country it actually parses as. All of that goes into ",[15,8726,8727],{},"details",". You don't show it to the user. You might log it to analytics. You might use ",[15,8730,8731],{},"details.country"," to suggest \"you typed a Saudi number but Egypt is selected — switch?\"",[11,8734,8735],{},"Two passes look wasteful; in practice the parser is cached internally by libphonenumber-js, and you only pay the second cost on failure. Success is the 95% case.",[11,8737,8738,8739,8742],{},"The argument to ",[15,8740,8741],{},"validate()"," is deliberately two-shaped:",[41,8744,8746],{"className":187,"code":8745,"language":189,"meta":46,"style":46},"type ValidateArgs =\n  | { country: { iso2: string; dial_code?: string } | null | undefined; phone?: undefined }\n  | { country: { iso2: string; dial_code?: string } | null | undefined; phone: string | null };\n",[15,8747,8748,8757,8803],{"__ignoreMap":46},[50,8749,8750,8752,8755],{"class":52,"line":53},[50,8751,1823],{"class":196},[50,8753,8754],{"class":203}," ValidateArgs",[50,8756,8281],{"class":196},[50,8758,8759,8761,8763,8765,8767,8769,8771,8773,8775,8777,8779,8781,8783,8785,8787,8789,8791,8793,8795,8797,8799,8801],{"class":52,"line":59},[50,8760,4489],{"class":196},[50,8762,2856],{"class":207},[50,8764,6027],{"class":226},[50,8766,230],{"class":196},[50,8768,2856],{"class":207},[50,8770,7304],{"class":226},[50,8772,230],{"class":196},[50,8774,233],{"class":217},[50,8776,2866],{"class":207},[50,8778,8418],{"class":226},[50,8780,273],{"class":196},[50,8782,233],{"class":217},[50,8784,8425],{"class":207},[50,8786,293],{"class":196},[50,8788,4667],{"class":217},[50,8790,236],{"class":196},[50,8792,1752],{"class":217},[50,8794,2866],{"class":207},[50,8796,6009],{"class":226},[50,8798,273],{"class":196},[50,8800,1752],{"class":217},[50,8802,3502],{"class":207},[50,8804,8805,8807,8809,8811,8813,8815,8817,8819,8821,8823,8825,8827,8829,8831,8833,8835,8837,8839,8841,8843,8845,8847,8849,8851],{"class":52,"line":65},[50,8806,4489],{"class":196},[50,8808,2856],{"class":207},[50,8810,6027],{"class":226},[50,8812,230],{"class":196},[50,8814,2856],{"class":207},[50,8816,7304],{"class":226},[50,8818,230],{"class":196},[50,8820,233],{"class":217},[50,8822,2866],{"class":207},[50,8824,8418],{"class":226},[50,8826,273],{"class":196},[50,8828,233],{"class":217},[50,8830,8425],{"class":207},[50,8832,293],{"class":196},[50,8834,4667],{"class":217},[50,8836,236],{"class":196},[50,8838,1752],{"class":217},[50,8840,2866],{"class":207},[50,8842,6009],{"class":226},[50,8844,230],{"class":196},[50,8846,233],{"class":217},[50,8848,236],{"class":196},[50,8850,4667],{"class":217},[50,8852,2876],{"class":207},[11,8854,8855,8858,8859,8862,8863,8866,8867,8870],{},[15,8856,8857],{},"validate({ country })"," returns ",[15,8860,8861],{},"ok: true"," with the ",[15,8864,8865],{},"required"," metadata — placeholder, format hint, length range. ",[15,8868,8869],{},"validate({ country, phone })"," runs the full validation. The component uses the same function to ask \"what should I show as a placeholder for Egypt?\" and to ask \"is this Egyptian number valid?\"",[30,8872,8874],{"id":8873},"further-implementation-details","Further implementation details",[11,8876,8877],{},"A few things that don't fit into one chapter but cost me real hours before I learned them.",[1349,8879,8881,8884],{"id":8880},"defineexpose-over-emit",[15,8882,8883],{},"defineExpose"," over emit",[11,8886,8887],{},"Validation is exposed, not emitted. The parent reads it off a template ref:",[41,8889,8891],{"className":187,"code":8890,"language":189,"meta":46,"style":46},"defineExpose({ validation });\n",[15,8892,8893],{"__ignoreMap":46},[50,8894,8895,8897],{"class":52,"line":53},[50,8896,8883],{"class":203},[50,8898,8899],{"class":207},"({ validation });\n",[41,8901,8903],{"className":588,"code":8902,"language":590,"meta":46,"style":46},"\u003CATellInput ref=\"phoneRef\" v-model:phone=\"...\" v-model:country=\"...\" \u002F>\n",[15,8904,8905],{"__ignoreMap":46},[50,8906,8907,8909,8911,8913,8915,8918,8921,8923,8925,8927,8929,8931,8933,8935,8937,8939,8941,8943,8945,8947],{"class":52,"line":53},[50,8908,208],{"class":207},[50,8910,5979],{"class":599},[50,8912,4617],{"class":203},[50,8914,639],{"class":207},[50,8916,8917],{"class":311},"\"phoneRef\"",[50,8919,8920],{"class":203}," v-model",[50,8922,230],{"class":207},[50,8924,6009],{"class":203},[50,8926,639],{"class":207},[50,8928,3704],{"class":311},[50,8930,3198],{"class":196},[50,8932,3704],{"class":311},[50,8934,8920],{"class":203},[50,8936,230],{"class":207},[50,8938,6027],{"class":203},[50,8940,639],{"class":207},[50,8942,3704],{"class":311},[50,8944,3198],{"class":196},[50,8946,3704],{"class":311},[50,8948,1195],{"class":207},[11,8950,8951],{},"Validation isn't an event; it's a derivative of the v-model. Emitting it on every change creates a second state channel that can drift. Exposing it means consumers pull when they care — on submit, on blur, on a specific UI moment — and the v-model stays the single source of truth.",[1349,8953,8955],{"id":8954},"bidi-ltr-wrapper-rtl-input","Bidi: LTR wrapper, RTL input",[11,8957,8958,8959,8962],{},"This app is bilingual. Arabic pages are ",[15,8960,8961],{},"dir=\"rtl\"","; phone numbers read left-to-right in both languages.",[41,8964,8966],{"className":588,"code":8965,"language":590,"meta":46,"style":46},"\u003Cdiv :class=\"wrapperClass\" dir=\"ltr\">\n  \u003CCountrySelect ... \u002F>\n  \u003Cinput v-model=\"mobileModel\" dir=\"rtl\" ... \u002F>\n\u003C\u002Fdiv>\n",[15,8967,8968,8998,9003,9008],{"__ignoreMap":46},[50,8969,8970,8972,8974,8976,8979,8981,8983,8986,8988,8991,8993,8996],{"class":52,"line":53},[50,8971,208],{"class":207},[50,8973,3736],{"class":599},[50,8975,993],{"class":207},[50,8977,8978],{"class":203},"class",[50,8980,639],{"class":207},[50,8982,3704],{"class":311},[50,8984,8985],{"class":207},"wrapperClass",[50,8987,3704],{"class":311},[50,8989,8990],{"class":203}," dir",[50,8992,639],{"class":207},[50,8994,8995],{"class":311},"\"ltr\"",[50,8997,603],{"class":207},[50,8999,9000],{"class":52,"line":59},[50,9001,9002],{"class":207},"  \u003CCountrySelect ... \u002F>\n",[50,9004,9005],{"class":52,"line":65},[50,9006,9007],{"class":207},"  \u003Cinput v-model=\"mobileModel\" dir=\"rtl\" ... \u002F>\n",[50,9009,9010,9012,9014],{"class":52,"line":71},[50,9011,739],{"class":207},[50,9013,3736],{"class":599},[50,9015,603],{"class":207},[11,9017,9018,9019,9022,9023,9026,9027,9030,9031,9034,9035,6174,9037,9040,9041,9043],{},"The wrapper is LTR so the country dropdown stays on the left. The input is RTL so the Arabic placeholder (",[15,9020,9021],{},"رقم الجوال",") reads correctly when empty. ",[15,9024,9025],{},"text-align: left"," on the input pins digits to the left edge when the user types. The dial code in the trigger gets an extra ",[15,9028,9029],{},"unicode-bidi: bidi-override"," so ",[15,9032,9033],{},"+966"," renders as ",[15,9036,9033],{},[15,9038,9039],{},"966+"," in Arabic browsers that occasionally treat the trailing ",[15,9042,7211],{}," as a weak character. This is a class of bug that only shows up in RTL and only for RTL users, so it's worth writing down.",[1349,9045,9047,9050],{"id":9046},"alloweddialcodes-disable-dont-filter",[15,9048,9049],{},"allowedDialCodes",": disable, don't filter",[11,9052,9053],{},"If you restrict the dropdown, the disallowed countries stay visible but greyed out:",[41,9055,9057],{"className":588,"code":9056,"language":590,"meta":46,"style":46},"\u003Cbutton\n  type=\"button\"\n  :disabled=\"isDisabled(option.raw_data.dial_digits)\"\n  :aria-disabled=\"isDisabled(option.raw_data.dial_digits)\"\n  :class=\"[isDisabled(option.raw_data.dial_digits) && 'opacity-50 cursor-not-allowed']\"\n>\n",[15,9058,9059,9066,9075,9094,9111,9137],{"__ignoreMap":46},[50,9060,9061,9063],{"class":52,"line":53},[50,9062,208],{"class":207},[50,9064,9065],{"class":599},"button\n",[50,9067,9068,9070,9072],{"class":52,"line":59},[50,9069,306],{"class":203},[50,9071,639],{"class":207},[50,9073,9074],{"class":311},"\"button\"\n",[50,9076,9077,9079,9082,9084,9086,9089,9092],{"class":52,"line":65},[50,9078,6041],{"class":207},[50,9080,9081],{"class":203},"disabled",[50,9083,639],{"class":207},[50,9085,3704],{"class":311},[50,9087,9088],{"class":203},"isDisabled",[50,9090,9091],{"class":207},"(option.raw_data.dial_digits)",[50,9093,3710],{"class":311},[50,9095,9096,9098,9101,9103,9105,9107,9109],{"class":52,"line":71},[50,9097,6041],{"class":207},[50,9099,9100],{"class":203},"aria-disabled",[50,9102,639],{"class":207},[50,9104,3704],{"class":311},[50,9106,9088],{"class":203},[50,9108,9091],{"class":207},[50,9110,3710],{"class":311},[50,9112,9113,9115,9117,9119,9121,9123,9125,9128,9130,9133,9135],{"class":52,"line":77},[50,9114,6041],{"class":207},[50,9116,8978],{"class":203},[50,9118,639],{"class":207},[50,9120,3704],{"class":311},[50,9122,2041],{"class":207},[50,9124,9088],{"class":203},[50,9126,9127],{"class":207},"(option.raw_data.dial_digits) ",[50,9129,1705],{"class":196},[50,9131,9132],{"class":311}," 'opacity-50 cursor-not-allowed'",[50,9134,6061],{"class":207},[50,9136,3710],{"class":311},[50,9138,9139],{"class":52,"line":83},[50,9140,603],{"class":207},[11,9142,9143,9144,9146,9147,9149],{},"Greying signals \"this exists, we don't accept it here.\" Filtering would signal \"this country doesn't exist,\" which is wrong. ",[15,9145,9100],{}," mirrors ",[15,9148,9081],{}," so screen readers hear the same message.",[1349,9151,9153],{"id":9152},"size-variants-as-lookup-tables","Size variants as lookup tables",[11,9155,9156,9157,230],{},"Four of them — wrapper, input, trigger, search-input — each typed as ",[15,9158,9159],{},"Record\u003CTellInputSize, string>",[41,9161,9163],{"className":187,"code":9162,"language":189,"meta":46,"style":46},"const wrapperSizeClasses: Record\u003CTellInputSize, string> = {\n  default: 'h-9 rounded-md',\n  sm: 'h-8 rounded-md gap-2',\n  lg: 'h-10 rounded-lg',\n  xl: 'h-14 rounded-lg text-base',\n};\n",[15,9164,9165,9191,9201,9211,9221,9231],{"__ignoreMap":46},[50,9166,9167,9169,9172,9174,9176,9178,9181,9183,9185,9187,9189],{"class":52,"line":53},[50,9168,952],{"class":196},[50,9170,9171],{"class":217}," wrapperSizeClasses",[50,9173,230],{"class":196},[50,9175,276],{"class":203},[50,9177,208],{"class":207},[50,9179,9180],{"class":203},"TellInputSize",[50,9182,284],{"class":207},[50,9184,281],{"class":217},[50,9186,290],{"class":207},[50,9188,639],{"class":196},[50,9190,968],{"class":207},[50,9192,9193,9196,9199],{"class":52,"line":59},[50,9194,9195],{"class":207},"  default: ",[50,9197,9198],{"class":311},"'h-9 rounded-md'",[50,9200,999],{"class":207},[50,9202,9203,9206,9209],{"class":52,"line":65},[50,9204,9205],{"class":207},"  sm: ",[50,9207,9208],{"class":311},"'h-8 rounded-md gap-2'",[50,9210,999],{"class":207},[50,9212,9213,9216,9219],{"class":52,"line":71},[50,9214,9215],{"class":207},"  lg: ",[50,9217,9218],{"class":311},"'h-10 rounded-lg'",[50,9220,999],{"class":207},[50,9222,9223,9226,9229],{"class":52,"line":77},[50,9224,9225],{"class":207},"  xl: ",[50,9227,9228],{"class":311},"'h-14 rounded-lg text-base'",[50,9230,999],{"class":207},[50,9232,9233],{"class":52,"line":83},[50,9234,1319],{"class":207},[11,9236,9237,9238,9241],{},"Adding a size is one line per map, and the ",[15,9239,9240],{},"Record\u003CTellInputSize, ...>"," type forces the compiler to remind me about every map when I add a value. No \"I forgot to update the search-input sizing\" bugs. The alternative — a chain of ternaries — scatters the same information across four places and relies on me remembering all four.",[1349,9243,9245],{"id":9244},"the-composable-that-isnt-a-singleton","The composable that isn't a singleton",[11,9247,9248,9251,9252,9254,9255,9258,9259,9262],{},[15,9249,9250],{},"usePhoneValidation()"," gets called twice — once in ",[15,9253,5979],{},", once in ",[15,9256,9257],{},"ACountrySelect",". Each call creates fresh refs. That ",[504,9260,9261],{},"looks"," like duplicated state, but in practice the first call fetches from localStorage or network, and the second call finds the data already in localStorage and skips the fetch. One network request, two arrays of ~80KB in memory. Hoisting to a singleton would save the memory; in exchange, I'd have global state that complicates SSR hydration and isolated-component testing. For two call-sites the duplication is the cheaper design. If I ever use the composable in five places, I'll revisit.",[30,9264,9266],{"id":9265},"the-parts-id-take-into-the-next-one","The parts I'd take into the next one",[11,9268,9269],{},"Phone inputs share a shape with a lot of other \"parser-shaped validator\" fields — IBAN, credit card, date parsers, address inputs. If I'm building any of those, the patterns I'd carry across:",[2163,9271,9272,9278,9284,9300,9306,9312,9320],{},[2166,9273,9274,9277],{},[2169,9275,9276],{},"A three-tier data flow"," — remote API, localStorage with a versioned key, hardcoded fallback. Each tier handles a specific failure of the tier above. Silent degradation beats visible breakage.",[2166,9279,9280,9283],{},[2169,9281,9282],{},"Cache the normalized shape",", not the raw response. Normalization is the slow part. Don't pay it twice.",[2166,9285,9286,9289,9290,9292,9293,9296,9297,9299],{},[2169,9287,9288],{},"Multiple indexes over one canonical array."," Array for rendering, ",[15,9291,6805],{}," for O(1) lookup, ",[15,9294,9295],{},"Map\u003Cstring, T[]>"," for cases where identity collisions are real (",[15,9298,6758],{}," countries, surname lookups in large user lists).",[2166,9301,9302,9305],{},[2169,9303,9304],{},"Precomputed search keys with symmetrical normalization."," Don't lowercase 250 strings per keystroke. Normalize at ingest, normalize at query, compare what's already normal.",[2166,9307,9308,9311],{},[2169,9309,9310],{},"Discriminated validation reasons."," \"Invalid\" is a lazy message. The consumer always has better context for the error than the validator does.",[2166,9313,9314,2185,9317,9319],{},[2169,9315,9316],{},"Expose derived state, don't emit it.",[15,9318,8883],{}," over a validation event. The v-model stays the source of truth; the parent reads when it cares.",[2166,9321,9322,9329],{},[2169,9323,9324,9325,9328],{},"Write ",[15,9326,9327],{},"input.value"," synchronously when the handler sanitizes."," The cursor-jump bug is real; the one-line fix is real.",[11,9331,9332],{},"The component is 160 lines; the composable is 540. That ratio feels right — the UI is thin, the thinking is in the data layer. That's most of what makes a component stop being the thing you dread touching.",[4011,9334,9335],{},"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 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 .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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":46,"searchDepth":59,"depth":59,"links":9337},[9338,9339,9340,9341,9343,9345,9346,9355],{"id":5983,"depth":59,"text":5984},{"id":6192,"depth":59,"text":6193},{"id":6762,"depth":59,"text":6763},{"id":7574,"depth":59,"text":9342},"Placeholders from libphonenumber-js example numbers",{"id":8004,"depth":59,"text":9344},"onInput: the handler that rewrites the input",{"id":8253,"depth":59,"text":8254},{"id":8873,"depth":59,"text":8874,"children":9347},[9348,9350,9351,9353,9354],{"id":8880,"depth":65,"text":9349},"defineExpose over emit",{"id":8954,"depth":65,"text":8955},{"id":9046,"depth":65,"text":9352},"allowedDialCodes: disable, don't filter",{"id":9152,"depth":65,"text":9153},{"id":9244,"depth":65,"text":9245},{"id":9265,"depth":59,"text":9266},"A Vue phone input backed by REST Countries and libphonenumber-js — three-tier data loading, bucketed indexes, dynamic placeholders, seven validation reasons.",[9358,5948,6181,9359,9360,9361,9362,9363,9364,8883,5953,4049],"phone input","E.164","RTL","Arabic","country code","form validation","defineModel",{},"\u002Fblog\u002Finside-atell-input-phone-validation",{"title":5961,"description":9356},"blog\u002Finside-atell-input-phone-validation","XMUiwUyGWv8YB9GoA869x-zO-wyPXEeCmNNuUDSGJ98",{"id":9371,"title":9372,"body":9373,"date":4037,"description":14265,"draft":4039,"extension":4040,"image":4041,"keywords":14266,"lang":4041,"meta":14275,"navigation":116,"path":14276,"seo":14277,"stem":14278,"updatedAt":4037,"__hash__":14279},"blog\u002Fblog\u002Fupload-and-download-progress-in-the-browser.md","Custom fetch wrapper, and XHR for upload progress",{"type":8,"value":9374,"toc":14241},[9375,9391,9410,9420,9423,9444,9451,9457,9460,9484,9500,9520,9542,9573,9586,9594,9598,9613,9616,9620,9623,9745,9748,9751,10012,10026,10033,10038,10080,10083,10269,10289,10293,10296,10845,10852,10855,10924,10927,10998,11002,11005,11213,11220,11224,11227,11362,11367,11370,11377,11383,11544,11550,11570,11573,11577,11580,11728,11731,11752,11765,11778,11788,11797,11856,11886,11889,11995,12003,12007,12010,12405,12408,12450,12454,12460,12797,12820,12838,12844,12853,12865,12868,13665,13668,13680,13699,13909,13930,13963,13967,13982,14064,14076,14095,14099,14102,14115,14129,14140,14155,14172,14176,14179,14235,14238],[11,9376,9377,9378,9380,9381,9383,9384,7205,9387,9390],{},"Anytime I need to talk to an HTTP API from the browser, I reach for ",[15,9379,6704],{}," first. It's the right primitive: Promise-first, composable with ",[15,9382,5950],{},", shares ",[15,9385,9386],{},"Request",[15,9388,9389],{},"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,9392,9393,9394,9396,9397,9399,9400,9402,9403,9406,9407,9409],{},"But ",[15,9395,6704],{}," has one gap that nobody warns you about, and it's not the kind of gap someone's going to fix. It's structural: ",[15,9398,6704],{}," doesn't emit upload progress events. The ",[15,9401,9386],{}," body can be a ",[15,9404,9405],{},"ReadableStream",", but browsers don't fire ",[15,9408,8338],{}," as they drain bytes out of it onto the wire. You hand the body to the network stack and it goes.",[11,9411,9412,9413,9416,9417,181],{},"For a real upload progress bar — not a fake indeterminate spinner — you need ",[15,9414,9415],{},"XMLHttpRequest",". It's the older API, but it's the only one that exposes ",[15,9418,9419],{},"xhr.upload.onprogress",[11,9421,9422],{},"This post covers three things, in order:",[9424,9425,9426,9432,9438],"ol",{},[2166,9427,9428,9429,9431],{},"Why native ",[15,9430,6704],{}," earns its default status, and the specific benefits you're getting from it.",[2166,9433,9434,9435,9437],{},"How to build a custom fetch wrapper — defaults, timeouts, interceptors, retry — without losing ",[15,9436,6704],{},"'s shape.",[2166,9439,9440,9441,9443],{},"The XHR gap, the progress event map, and a small XHR-backed wrapper that returns a ",[15,9442,9389],{}," so the rest of your code stays fetch-shaped.",[11,9445,9446,9447,9450],{},"If you've ever wondered why so many production codebases have a ",[15,9448,9449],{},"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.",[30,9452,9428,9454,9456],{"id":9453},"why-native-fetch-is-the-default-primitive",[15,9455,6704],{}," is the default primitive",[11,9458,9459],{},"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,9461,9462,2185,9465,9468,9469,9472,9473,9476,9477,9480,9481,9483],{},[2169,9463,9464],{},"One Promise, one Response.",[15,9466,9467],{},"fetch(url, init)"," returns a ",[15,9470,9471],{},"Promise\u003CResponse>",". No state machine, no ",[15,9474,9475],{},"readyState",", no event-handler wiring for the happy path. ",[15,9478,9479],{},"await fetch(...)"," reads like every other async call in your codebase. XHR is still event-driven under the hood; ",[15,9482,6704],{}," is the Promise-shaped interface.",[11,9485,9486,9489,9490,9492,9493,9495,9496,9499],{},[2169,9487,9488],{},"Unified Request\u002FResponse primitives."," The ",[15,9491,9389],{}," you get from ",[15,9494,6704],{}," is the same type Service Workers return, the Cache API stores, and edge runtimes accept. You can ",[15,9497,9498],{},".clone()"," a response, hand it back to the browser, cache it, or pipe it elsewhere — no translation layer.",[11,9501,9502,9507,9508,9511,9512,9515,9516,9519],{},[2169,9503,9504,9505,181],{},"Cancellation via ",[15,9506,5950],{}," One signal can cancel a whole chain of work — a fetch, a downstream computation, a retry that hasn't fired yet. ",[15,9509,9510],{},"AbortSignal.any([...])"," composes multiple signals into one; ",[15,9513,9514],{},"AbortSignal.timeout(ms)"," gives you a timeout without a ",[15,9517,9518],{},"setTimeout\u002FclearTimeout"," dance. Cancellation is a platform feature, not a library concern.",[11,9521,9522,2185,9525,8347,9528,9530,9531,284,9534,9537,9538,9541],{},[2169,9523,9524],{},"Streaming bodies.",[15,9526,9527],{},"Response.body",[15,9529,9405],{},". 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 (",[15,9532,9533],{},"createImageBitmap",[15,9535,9536],{},"new Response(stream)",", the Cache API). ",[15,9539,9540],{},"Request.body"," accepts streams too, though upload progress is the one place this doesn't translate.",[11,9543,9544,2185,9547,284,9550,284,9553,284,9556,284,9558,284,9561,9564,9565,9568,9569,9572],{},[2169,9545,9546],{},"First-class request options.",[15,9548,9549],{},"credentials",[15,9551,9552],{},"cache",[15,9554,9555],{},"redirect",[15,9557,4702],{},[15,9559,9560],{},"referrer",[15,9562,9563],{},"integrity"," — all top-level ",[15,9566,9567],{},"RequestInit"," fields. No ",[15,9570,9571],{},"xhr.withCredentials = true"," side-effect, no forbidden-header workarounds. The API was designed with modern security and caching semantics in mind.",[11,9574,9575,9578,9579,9581,9582,9585],{},[2169,9576,9577],{},"Cross-runtime consistency."," The same ",[15,9580,6704],{}," signature runs in browsers, Node 18+, Bun, Deno, Cloudflare Workers, and Service Workers. An SSR-capable client doesn't need a ",[15,9583,9584],{},"typeof window"," branch. Server-rendered code and client-rendered code can share one HTTP layer.",[11,9587,9588,2185,9591,9593],{},[2169,9589,9590],{},"Extensibility without class hierarchies.",[15,9592,6704],{}," 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.",[30,9595,9597],{"id":9596},"building-a-custom-fetch-wrapper","Building a custom fetch wrapper",[11,9599,9600,9601,9603,9604,1390,9606,9609,9610,9612],{},"Most production codebases don't call ",[15,9602,6704],{}," directly. They have a ",[15,9605,9449],{},[15,9607,9608],{},"apiClient"," that layers on defaults, auth headers, timeouts, retry, error normalization, and sometimes interceptors. The trick is to build it without losing ",[15,9611,6704],{},"'s shape — so that swapping in a different transport (like the XHR-backed one below) doesn't force a rewrite of every consumer.",[11,9614,9615],{},"I'll walk through four layers, each small, each earning its weight.",[1349,9617,9619],{"id":9618},"layer-1-base-url-default-headers-parsed-json","Layer 1: base URL, default headers, parsed JSON",[11,9621,9622],{},"Start with the shortest useful wrapper:",[41,9624,9626],{"className":187,"code":9625,"language":189,"meta":46,"style":46},"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",[15,9627,9628,9677,9692,9721,9741],{"__ignoreMap":46},[50,9629,9630,9632,9634,9636,9639,9641,9643,9645,9648,9650,9652,9654,9657,9659,9662,9664,9667,9669,9671,9673,9675],{"class":52,"line":53},[50,9631,197],{"class":196},[50,9633,4697],{"class":196},[50,9635,4231],{"class":196},[50,9637,9638],{"class":203}," request",[50,9640,208],{"class":207},[50,9642,211],{"class":203},[50,9644,4670],{"class":207},[50,9646,9647],{"class":226},"path",[50,9649,230],{"class":196},[50,9651,233],{"class":217},[50,9653,284],{"class":207},[50,9655,9656],{"class":226},"init",[50,9658,230],{"class":196},[50,9660,9661],{"class":203}," RequestInit",[50,9663,214],{"class":196},[50,9665,9666],{"class":207}," {})",[50,9668,230],{"class":196},[50,9670,4242],{"class":203},[50,9672,208],{"class":207},[50,9674,211],{"class":203},[50,9676,221],{"class":207},[50,9678,9679,9681,9683,9685,9687,9689],{"class":52,"line":59},[50,9680,1439],{"class":196},[50,9682,6208],{"class":217},[50,9684,214],{"class":196},[50,9686,4325],{"class":196},[50,9688,6215],{"class":203},[50,9690,9691],{"class":207},"(path, init);\n",[50,9693,9694,9696,9698,9700,9702,9704,9706,9709,9712,9714,9716,9719],{"class":52,"line":65},[50,9695,1270],{"class":196},[50,9697,420],{"class":207},[50,9699,1275],{"class":196},[50,9701,6495],{"class":207},[50,9703,6498],{"class":196},[50,9705,2279],{"class":196},[50,9707,9708],{"class":203}," ApiError",[50,9710,9711],{"class":207},"(res.status, ",[50,9713,6534],{"class":196},[50,9715,6537],{"class":207},[50,9717,9718],{"class":203},"text",[50,9720,6823],{"class":207},[50,9722,9723,9725,9727,9729,9731,9733,9735,9737,9739],{"class":52,"line":71},[50,9724,973],{"class":196},[50,9726,6537],{"class":207},[50,9728,6540],{"class":203},[50,9730,1570],{"class":207},[50,9732,1537],{"class":196},[50,9734,4242],{"class":203},[50,9736,208],{"class":207},[50,9738,211],{"class":203},[50,9740,246],{"class":207},[50,9742,9743],{"class":52,"line":77},[50,9744,170],{"class":207},[11,9746,9747],{},"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,9749,9750],{},"A slightly more useful shape accepts configuration:",[41,9752,9754],{"className":187,"code":9753,"language":189,"meta":46,"style":46},"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",[15,9755,9756,9773,9784,9803,9808,9832,9836,9880,9902,9938,9958,9984,10004,10008],{"__ignoreMap":46},[50,9757,9758,9760,9762,9765,9767,9769,9771],{"class":52,"line":53},[50,9759,197],{"class":196},[50,9761,4231],{"class":196},[50,9763,9764],{"class":203}," createClient",[50,9766,1073],{"class":207},[50,9768,6254],{"class":226},[50,9770,230],{"class":196},[50,9772,968],{"class":207},[50,9774,9775,9778,9780,9782],{"class":52,"line":59},[50,9776,9777],{"class":226},"  baseURL",[50,9779,230],{"class":196},[50,9781,233],{"class":217},[50,9783,325],{"class":207},[50,9785,9786,9789,9791,9793,9795,9797,9799,9801],{"class":52,"line":65},[50,9787,9788],{"class":226},"  defaultHeaders",[50,9790,273],{"class":196},[50,9792,276],{"class":203},[50,9794,208],{"class":207},[50,9796,281],{"class":217},[50,9798,284],{"class":207},[50,9800,281],{"class":217},[50,9802,246],{"class":207},[50,9804,9805],{"class":52,"line":71},[50,9806,9807],{"class":207},"}) {\n",[50,9809,9810,9812,9814,9817,9819,9822,9824,9827,9829],{"class":52,"line":77},[50,9811,1439],{"class":196},[50,9813,2856],{"class":207},[50,9815,9816],{"class":217},"baseURL",[50,9818,284],{"class":207},[50,9820,9821],{"class":217},"defaultHeaders",[50,9823,214],{"class":196},[50,9825,9826],{"class":207}," {} } ",[50,9828,639],{"class":196},[50,9830,9831],{"class":207}," options;\n",[50,9833,9834],{"class":52,"line":83},[50,9835,117],{"emptyLinePlaceholder":116},[50,9837,9838,9840,9842,9844,9846,9848,9850,9852,9854,9856,9858,9860,9862,9864,9866,9868,9870,9872,9874,9876,9878],{"class":52,"line":89},[50,9839,973],{"class":196},[50,9841,4697],{"class":196},[50,9843,4231],{"class":196},[50,9845,9638],{"class":203},[50,9847,208],{"class":207},[50,9849,211],{"class":203},[50,9851,4670],{"class":207},[50,9853,9647],{"class":226},[50,9855,230],{"class":196},[50,9857,233],{"class":217},[50,9859,284],{"class":207},[50,9861,9656],{"class":226},[50,9863,230],{"class":196},[50,9865,9661],{"class":203},[50,9867,214],{"class":196},[50,9869,9666],{"class":207},[50,9871,230],{"class":196},[50,9873,4242],{"class":203},[50,9875,208],{"class":207},[50,9877,211],{"class":203},[50,9879,221],{"class":207},[50,9881,9882,9884,9887,9889,9891,9894,9897,9900],{"class":52,"line":95},[50,9883,3224],{"class":196},[50,9885,9886],{"class":217}," url",[50,9888,214],{"class":196},[50,9890,2279],{"class":196},[50,9892,9893],{"class":203}," URL",[50,9895,9896],{"class":207},"(path, baseURL).",[50,9898,9899],{"class":203},"toString",[50,9901,1999],{"class":207},[50,9903,9904,9906,9909,9911,9913,9915,9918,9920,9923,9925,9927,9929,9931,9933,9935],{"class":52,"line":101},[50,9905,3224],{"class":196},[50,9907,9908],{"class":217}," headers",[50,9910,214],{"class":196},[50,9912,2856],{"class":207},[50,9914,3198],{"class":196},[50,9916,9917],{"class":207},"defaultHeaders, ",[50,9919,3198],{"class":196},[50,9921,9922],{"class":207},"(init.headers ",[50,9924,1537],{"class":196},[50,9926,276],{"class":203},[50,9928,208],{"class":207},[50,9930,281],{"class":217},[50,9932,284],{"class":207},[50,9934,281],{"class":217},[50,9936,9937],{"class":207},">) };\n",[50,9939,9940,9942,9944,9946,9948,9950,9953,9955],{"class":52,"line":107},[50,9941,3224],{"class":196},[50,9943,6208],{"class":217},[50,9945,214],{"class":196},[50,9947,4325],{"class":196},[50,9949,6215],{"class":203},[50,9951,9952],{"class":207},"(url, { ",[50,9954,3198],{"class":196},[50,9956,9957],{"class":207},"init, headers });\n",[50,9959,9960,9962,9964,9966,9968,9970,9972,9974,9976,9978,9980,9982],{"class":52,"line":113},[50,9961,1699],{"class":196},[50,9963,420],{"class":207},[50,9965,1275],{"class":196},[50,9967,6495],{"class":207},[50,9969,6498],{"class":196},[50,9971,2279],{"class":196},[50,9973,9708],{"class":203},[50,9975,9711],{"class":207},[50,9977,6534],{"class":196},[50,9979,6537],{"class":207},[50,9981,9718],{"class":203},[50,9983,6823],{"class":207},[50,9985,9986,9988,9990,9992,9994,9996,9998,10000,10002],{"class":52,"line":120},[50,9987,1558],{"class":196},[50,9989,6537],{"class":207},[50,9991,6540],{"class":203},[50,9993,1570],{"class":207},[50,9995,1537],{"class":196},[50,9997,4242],{"class":203},[50,9999,208],{"class":207},[50,10001,211],{"class":203},[50,10003,246],{"class":207},[50,10005,10006],{"class":52,"line":126},[50,10007,8710],{"class":207},[50,10009,10010],{"class":52,"line":132},[50,10011,170],{"class":207},[11,10013,10014,10017,10018,10021,10022,10025],{},[15,10015,10016],{},"new URL(path, baseURL)"," is the piece worth stealing. It handles leading and trailing slashes correctly without the manual ",[15,10019,10020],{},"${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 ",[15,10023,10024],{},"URL"," uses it as-is.",[1349,10027,10029,10030],{"id":10028},"layer-2-timeouts-via-abortsignal","Layer 2: timeouts via ",[15,10031,10032],{},"AbortSignal",[11,10034,10035,10036,230],{},"The right way to do timeouts is ",[15,10037,9514],{},[41,10039,10041],{"className":187,"code":10040,"language":189,"meta":46,"style":46},"const signal = AbortSignal.timeout(30_000);\nconst res = await fetch(url, { signal });\n",[15,10042,10043,10065],{"__ignoreMap":46},[50,10044,10045,10047,10050,10052,10055,10058,10060,10063],{"class":52,"line":53},[50,10046,952],{"class":196},[50,10048,10049],{"class":217}," signal",[50,10051,214],{"class":196},[50,10053,10054],{"class":207}," AbortSignal.",[50,10056,10057],{"class":203},"timeout",[50,10059,1073],{"class":207},[50,10061,10062],{"class":217},"30_000",[50,10064,1971],{"class":207},[50,10066,10067,10069,10071,10073,10075,10077],{"class":52,"line":59},[50,10068,952],{"class":196},[50,10070,6208],{"class":217},[50,10072,214],{"class":196},[50,10074,4325],{"class":196},[50,10076,6215],{"class":203},[50,10078,10079],{"class":207},"(url, { signal });\n",[11,10081,10082],{},"If a caller also passes their own signal, merge them so either one can cancel the request:",[41,10084,10086],{"className":187,"code":10085,"language":189,"meta":46,"style":46},"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",[15,10087,10088,10124,10140,10156,10180,10185,10201,10222,10241,10258,10265],{"__ignoreMap":46},[50,10089,10090,10092,10095,10097,10099,10101,10104,10106,10108,10110,10112,10114,10116,10118,10120,10122],{"class":52,"line":53},[50,10091,1895],{"class":196},[50,10093,10094],{"class":203}," mergeSignals",[50,10096,1073],{"class":207},[50,10098,3208],{"class":226},[50,10100,273],{"class":196},[50,10102,10103],{"class":203}," AbortSignal",[50,10105,284],{"class":207},[50,10107,3213],{"class":226},[50,10109,273],{"class":196},[50,10111,10103],{"class":203},[50,10113,1643],{"class":207},[50,10115,230],{"class":196},[50,10117,10103],{"class":203},[50,10119,236],{"class":196},[50,10121,1752],{"class":217},[50,10123,968],{"class":207},[50,10125,10126,10128,10130,10132,10135,10137],{"class":52,"line":59},[50,10127,1270],{"class":196},[50,10129,420],{"class":207},[50,10131,1275],{"class":196},[50,10133,10134],{"class":207},"a) ",[50,10136,1281],{"class":196},[50,10138,10139],{"class":207}," b;\n",[50,10141,10142,10144,10146,10148,10151,10153],{"class":52,"line":65},[50,10143,1270],{"class":196},[50,10145,420],{"class":207},[50,10147,1275],{"class":196},[50,10149,10150],{"class":207},"b) ",[50,10152,1281],{"class":196},[50,10154,10155],{"class":207}," a;\n",[50,10157,10158,10160,10162,10165,10167,10170,10172,10174,10177],{"class":52,"line":71},[50,10159,1270],{"class":196},[50,10161,420],{"class":207},[50,10163,10164],{"class":311},"'any'",[50,10166,1936],{"class":196},[50,10168,10169],{"class":207}," AbortSignal) ",[50,10171,1281],{"class":196},[50,10173,10054],{"class":207},[50,10175,10176],{"class":203},"any",[50,10178,10179],{"class":207},"([a, b]);\n",[50,10181,10182],{"class":52,"line":77},[50,10183,10184],{"class":1853},"  \u002F\u002F fallback for older runtimes\n",[50,10186,10187,10189,10192,10194,10196,10199],{"class":52,"line":83},[50,10188,1439],{"class":196},[50,10190,10191],{"class":217}," controller",[50,10193,214],{"class":196},[50,10195,2279],{"class":196},[50,10197,10198],{"class":203}," AbortController",[50,10200,1999],{"class":207},[50,10202,10203,10205,10208,10210,10212,10214,10217,10220],{"class":52,"line":89},[50,10204,1439],{"class":196},[50,10206,10207],{"class":203}," onAbort",[50,10209,214],{"class":196},[50,10211,359],{"class":207},[50,10213,362],{"class":196},[50,10215,10216],{"class":207}," controller.",[50,10218,10219],{"class":203},"abort",[50,10221,1999],{"class":207},[50,10223,10224,10227,10229,10231,10234,10237,10239],{"class":52,"line":95},[50,10225,10226],{"class":207},"  a.",[50,10228,5462],{"class":203},[50,10230,1073],{"class":207},[50,10232,10233],{"class":311},"'abort'",[50,10235,10236],{"class":207},", onAbort, { once: ",[50,10238,3493],{"class":217},[50,10240,6134],{"class":207},[50,10242,10243,10246,10248,10250,10252,10254,10256],{"class":52,"line":101},[50,10244,10245],{"class":207},"  b.",[50,10247,5462],{"class":203},[50,10249,1073],{"class":207},[50,10251,10233],{"class":311},[50,10253,10236],{"class":207},[50,10255,3493],{"class":217},[50,10257,6134],{"class":207},[50,10259,10260,10262],{"class":52,"line":107},[50,10261,973],{"class":196},[50,10263,10264],{"class":207}," controller.signal;\n",[50,10266,10267],{"class":52,"line":113},[50,10268,170],{"class":207},[11,10270,10271,10273,10274,10276,10277,10280,10281,10284,10285,10288],{},[15,10272,9510],{}," does the merge natively in recent browsers; the fallback manually forwards both signals into a new controller. Either way, a single signal flows into ",[15,10275,6704],{},", and a timeout ",[504,10278,10279],{},"or"," a caller abort resolves to the same rejection path — which matters, because downstream ",[15,10282,10283],{},"try\u002Fcatch"," can check ",[15,10286,10287],{},"err.name === 'AbortError'"," regardless of which source fired.",[1349,10290,10292],{"id":10291},"layer-3-interceptors","Layer 3: interceptors",[11,10294,10295],{},"Three chains — request, response, error — turn a static client into an extensible one:",[41,10297,10299],{"className":187,"code":10298,"language":189,"meta":46,"style":46},"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",[15,10300,10301,10352,10386,10390,10431,10448,10465,10469,10511,10522,10539,10578,10582,10605,10622,10646,10672,10692,10696,10700,10725,10735,10764,10768,10791,10800,10826,10830,10834,10841],{"__ignoreMap":46},[50,10302,10303,10305,10308,10310,10312,10315,10317,10319,10322,10324,10326,10328,10330,10332,10334,10337,10339,10342,10344,10346,10348,10350],{"class":52,"line":53},[50,10304,1823],{"class":196},[50,10306,10307],{"class":203}," RequestInterceptor",[50,10309,214],{"class":196},[50,10311,420],{"class":207},[50,10313,10314],{"class":226},"ctx",[50,10316,230],{"class":196},[50,10318,2856],{"class":207},[50,10320,10321],{"class":226},"url",[50,10323,230],{"class":196},[50,10325,233],{"class":217},[50,10327,2866],{"class":207},[50,10329,9656],{"class":226},[50,10331,230],{"class":196},[50,10333,9661],{"class":203},[50,10335,10336],{"class":207}," }) ",[50,10338,362],{"class":196},[50,10340,10341],{"class":217}," void",[50,10343,236],{"class":196},[50,10345,4242],{"class":203},[50,10347,208],{"class":207},[50,10349,5906],{"class":217},[50,10351,246],{"class":207},[50,10353,10354,10356,10359,10361,10363,10365,10367,10370,10372,10374,10376,10378,10380,10382,10384],{"class":52,"line":59},[50,10355,1823],{"class":196},[50,10357,10358],{"class":203}," ResponseInterceptor",[50,10360,214],{"class":196},[50,10362,420],{"class":207},[50,10364,6511],{"class":226},[50,10366,230],{"class":196},[50,10368,10369],{"class":203}," Response",[50,10371,440],{"class":207},[50,10373,362],{"class":196},[50,10375,10369],{"class":203},[50,10377,236],{"class":196},[50,10379,4242],{"class":203},[50,10381,208],{"class":207},[50,10383,9389],{"class":203},[50,10385,246],{"class":207},[50,10387,10388],{"class":52,"line":65},[50,10389,117],{"emptyLinePlaceholder":116},[50,10391,10392,10394,10396,10398,10400,10402,10404,10406,10408,10410,10412,10414,10416,10418,10420,10422,10424,10426,10428],{"class":52,"line":71},[50,10393,197],{"class":196},[50,10395,4231],{"class":196},[50,10397,9764],{"class":203},[50,10399,1073],{"class":207},[50,10401,6254],{"class":226},[50,10403,230],{"class":196},[50,10405,2856],{"class":207},[50,10407,9816],{"class":226},[50,10409,230],{"class":196},[50,10411,233],{"class":217},[50,10413,2866],{"class":207},[50,10415,9821],{"class":226},[50,10417,273],{"class":196},[50,10419,276],{"class":203},[50,10421,208],{"class":207},[50,10423,281],{"class":217},[50,10425,284],{"class":207},[50,10427,281],{"class":217},[50,10429,10430],{"class":207},"> }) {\n",[50,10432,10433,10435,10438,10440,10442,10444,10446],{"class":52,"line":77},[50,10434,1439],{"class":196},[50,10436,10437],{"class":217}," requestInterceptors",[50,10439,230],{"class":196},[50,10441,10307],{"class":203},[50,10443,7413],{"class":207},[50,10445,639],{"class":196},[50,10447,5242],{"class":207},[50,10449,10450,10452,10455,10457,10459,10461,10463],{"class":52,"line":83},[50,10451,1439],{"class":196},[50,10453,10454],{"class":217}," responseInterceptors",[50,10456,230],{"class":196},[50,10458,10358],{"class":203},[50,10460,7413],{"class":207},[50,10462,639],{"class":196},[50,10464,5242],{"class":207},[50,10466,10467],{"class":52,"line":89},[50,10468,117],{"emptyLinePlaceholder":116},[50,10470,10471,10473,10475,10477,10479,10481,10483,10485,10487,10489,10491,10493,10495,10497,10499,10501,10503,10505,10507,10509],{"class":52,"line":95},[50,10472,5213],{"class":196},[50,10474,4231],{"class":196},[50,10476,9638],{"class":203},[50,10478,208],{"class":207},[50,10480,211],{"class":203},[50,10482,4670],{"class":207},[50,10484,9647],{"class":226},[50,10486,230],{"class":196},[50,10488,233],{"class":217},[50,10490,284],{"class":207},[50,10492,9656],{"class":226},[50,10494,230],{"class":196},[50,10496,9661],{"class":203},[50,10498,214],{"class":196},[50,10500,9666],{"class":207},[50,10502,230],{"class":196},[50,10504,4242],{"class":203},[50,10506,208],{"class":207},[50,10508,211],{"class":203},[50,10510,221],{"class":207},[50,10512,10513,10515,10518,10520],{"class":52,"line":101},[50,10514,3224],{"class":196},[50,10516,10517],{"class":217}," ctx",[50,10519,214],{"class":196},[50,10521,968],{"class":207},[50,10523,10524,10527,10529,10531,10534,10536],{"class":52,"line":107},[50,10525,10526],{"class":207},"      url: ",[50,10528,5184],{"class":196},[50,10530,9893],{"class":203},[50,10532,10533],{"class":207},"(path, options.baseURL).",[50,10535,9899],{"class":203},[50,10537,10538],{"class":207},"(),\n",[50,10540,10541,10544,10546,10549,10551,10554,10556,10559,10561,10563,10565,10567,10569,10571,10573,10575],{"class":52,"line":113},[50,10542,10543],{"class":207},"      init: { ",[50,10545,3198],{"class":196},[50,10547,10548],{"class":207},"init, headers: { ",[50,10550,3198],{"class":196},[50,10552,10553],{"class":207},"(options.defaultHeaders ",[50,10555,7034],{"class":196},[50,10557,10558],{"class":207}," {}), ",[50,10560,3198],{"class":196},[50,10562,9922],{"class":207},[50,10564,1537],{"class":196},[50,10566,276],{"class":203},[50,10568,208],{"class":207},[50,10570,281],{"class":217},[50,10572,284],{"class":207},[50,10574,281],{"class":217},[50,10576,10577],{"class":207},">) } },\n",[50,10579,10580],{"class":52,"line":120},[50,10581,159],{"class":207},[50,10583,10584,10587,10589,10591,10593,10595,10598,10600,10602],{"class":52,"line":126},[50,10585,10586],{"class":196},"    for",[50,10588,420],{"class":207},[50,10590,952],{"class":196},[50,10592,4278],{"class":217},[50,10594,6981],{"class":196},[50,10596,10597],{"class":207}," requestInterceptors) ",[50,10599,6534],{"class":196},[50,10601,4278],{"class":203},[50,10603,10604],{"class":207},"(ctx);\n",[50,10606,10607,10610,10613,10615,10617,10619],{"class":52,"line":132},[50,10608,10609],{"class":196},"    let",[50,10611,10612],{"class":207}," res ",[50,10614,639],{"class":196},[50,10616,4325],{"class":196},[50,10618,6215],{"class":203},[50,10620,10621],{"class":207},"(ctx.url, ctx.init);\n",[50,10623,10624,10626,10628,10630,10632,10634,10637,10639,10641,10643],{"class":52,"line":138},[50,10625,10586],{"class":196},[50,10627,420],{"class":207},[50,10629,952],{"class":196},[50,10631,4278],{"class":217},[50,10633,6981],{"class":196},[50,10635,10636],{"class":207}," responseInterceptors) res ",[50,10638,639],{"class":196},[50,10640,4325],{"class":196},[50,10642,4278],{"class":203},[50,10644,10645],{"class":207},"(res);\n",[50,10647,10648,10650,10652,10654,10656,10658,10660,10662,10664,10666,10668,10670],{"class":52,"line":144},[50,10649,1699],{"class":196},[50,10651,420],{"class":207},[50,10653,1275],{"class":196},[50,10655,6495],{"class":207},[50,10657,6498],{"class":196},[50,10659,2279],{"class":196},[50,10661,9708],{"class":203},[50,10663,9711],{"class":207},[50,10665,6534],{"class":196},[50,10667,6537],{"class":207},[50,10669,9718],{"class":203},[50,10671,6823],{"class":207},[50,10673,10674,10676,10678,10680,10682,10684,10686,10688,10690],{"class":52,"line":150},[50,10675,1558],{"class":196},[50,10677,6537],{"class":207},[50,10679,6540],{"class":203},[50,10681,1570],{"class":207},[50,10683,1537],{"class":196},[50,10685,4242],{"class":203},[50,10687,208],{"class":207},[50,10689,211],{"class":203},[50,10691,246],{"class":207},[50,10693,10694],{"class":52,"line":156},[50,10695,110],{"class":207},[50,10697,10698],{"class":52,"line":162},[50,10699,117],{"emptyLinePlaceholder":116},[50,10701,10702,10705,10708,10710,10712,10715,10717,10719,10721,10723],{"class":52,"line":167},[50,10703,10704],{"class":207},"  request.",[50,10706,10707],{"class":203},"useRequest",[50,10709,214],{"class":196},[50,10711,420],{"class":207},[50,10713,10714],{"class":226},"fn",[50,10716,230],{"class":196},[50,10718,10307],{"class":203},[50,10720,440],{"class":207},[50,10722,362],{"class":196},[50,10724,968],{"class":207},[50,10726,10727,10730,10732],{"class":52,"line":3263},[50,10728,10729],{"class":207},"    requestInterceptors.",[50,10731,7044],{"class":203},[50,10733,10734],{"class":207},"(fn);\n",[50,10736,10737,10739,10741,10743,10745,10748,10751,10754,10757,10760,10762],{"class":52,"line":3275},[50,10738,1558],{"class":196},[50,10740,359],{"class":207},[50,10742,362],{"class":196},[50,10744,10341],{"class":196},[50,10746,10747],{"class":207}," requestInterceptors.",[50,10749,10750],{"class":203},"splice",[50,10752,10753],{"class":207},"(requestInterceptors.",[50,10755,10756],{"class":203},"indexOf",[50,10758,10759],{"class":207},"(fn), ",[50,10761,5270],{"class":217},[50,10763,1971],{"class":207},[50,10765,10766],{"class":52,"line":6553},[50,10767,8710],{"class":207},[50,10769,10770,10772,10775,10777,10779,10781,10783,10785,10787,10789],{"class":52,"line":6569},[50,10771,10704],{"class":207},[50,10773,10774],{"class":203},"useResponse",[50,10776,214],{"class":196},[50,10778,420],{"class":207},[50,10780,10714],{"class":226},[50,10782,230],{"class":196},[50,10784,10358],{"class":203},[50,10786,440],{"class":207},[50,10788,362],{"class":196},[50,10790,968],{"class":207},[50,10792,10793,10796,10798],{"class":52,"line":6592},[50,10794,10795],{"class":207},"    responseInterceptors.",[50,10797,7044],{"class":203},[50,10799,10734],{"class":207},[50,10801,10802,10804,10806,10808,10810,10813,10815,10818,10820,10822,10824],{"class":52,"line":6600},[50,10803,1558],{"class":196},[50,10805,359],{"class":207},[50,10807,362],{"class":196},[50,10809,10341],{"class":196},[50,10811,10812],{"class":207}," responseInterceptors.",[50,10814,10750],{"class":203},[50,10816,10817],{"class":207},"(responseInterceptors.",[50,10819,10756],{"class":203},[50,10821,10759],{"class":207},[50,10823,5270],{"class":217},[50,10825,1971],{"class":207},[50,10827,10828],{"class":52,"line":6629},[50,10829,8710],{"class":207},[50,10831,10832],{"class":52,"line":6642},[50,10833,117],{"emptyLinePlaceholder":116},[50,10835,10836,10838],{"class":52,"line":6647},[50,10837,973],{"class":196},[50,10839,10840],{"class":207}," request;\n",[50,10842,10843],{"class":52,"line":6654},[50,10844,170],{"class":207},[11,10846,10847,10848,10851],{},"Each ",[15,10849,10850],{},"use*"," method returns an unsubscribe function, which matters when you register an interceptor from a component and want to clean up on unmount.",[11,10853,10854],{},"Auth becomes a one-liner:",[41,10856,10858],{"className":187,"code":10857,"language":189,"meta":46,"style":46},"api.useRequest((ctx) => {\n  ctx.init.headers = { ...(ctx.init.headers as Record\u003Cstring, string>), Authorization: `Bearer ${token.value}` };\n});\n",[15,10859,10860,10877,10920],{"__ignoreMap":46},[50,10861,10862,10865,10867,10869,10871,10873,10875],{"class":52,"line":53},[50,10863,10864],{"class":207},"api.",[50,10866,10707],{"class":203},[50,10868,1676],{"class":207},[50,10870,10314],{"class":226},[50,10872,440],{"class":207},[50,10874,362],{"class":196},[50,10876,968],{"class":207},[50,10878,10879,10882,10884,10886,10888,10891,10893,10895,10897,10899,10901,10903,10906,10909,10912,10914,10916,10918],{"class":52,"line":59},[50,10880,10881],{"class":207},"  ctx.init.headers ",[50,10883,639],{"class":196},[50,10885,2856],{"class":207},[50,10887,3198],{"class":196},[50,10889,10890],{"class":207},"(ctx.init.headers ",[50,10892,1537],{"class":196},[50,10894,276],{"class":203},[50,10896,208],{"class":207},[50,10898,281],{"class":217},[50,10900,284],{"class":207},[50,10902,281],{"class":217},[50,10904,10905],{"class":207},">), Authorization: ",[50,10907,10908],{"class":311},"`Bearer ${",[50,10910,10911],{"class":207},"token",[50,10913,181],{"class":311},[50,10915,5666],{"class":207},[50,10917,2049],{"class":311},[50,10919,2876],{"class":207},[50,10921,10922],{"class":52,"line":65},[50,10923,1153],{"class":207},[11,10925,10926],{},"So does \"refresh on 401\":",[41,10928,10930],{"className":187,"code":10929,"language":189,"meta":46,"style":46},"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",[15,10931,10932,10952,10970,10980,10994],{"__ignoreMap":46},[50,10933,10934,10936,10938,10940,10942,10944,10946,10948,10950],{"class":52,"line":53},[50,10935,10864],{"class":207},[50,10937,10774],{"class":203},[50,10939,1073],{"class":207},[50,10941,4228],{"class":196},[50,10943,420],{"class":207},[50,10945,6511],{"class":226},[50,10947,440],{"class":207},[50,10949,362],{"class":196},[50,10951,968],{"class":207},[50,10953,10954,10956,10959,10961,10964,10966,10968],{"class":52,"line":59},[50,10955,1270],{"class":196},[50,10957,10958],{"class":207}," (res.status ",[50,10960,1949],{"class":196},[50,10962,10963],{"class":217}," 401",[50,10965,440],{"class":207},[50,10967,1281],{"class":196},[50,10969,7487],{"class":207},[50,10971,10972,10975,10978],{"class":52,"line":65},[50,10973,10974],{"class":196},"  await",[50,10976,10977],{"class":203}," refreshAuth",[50,10979,1999],{"class":207},[50,10981,10982,10984,10986,10989,10992],{"class":52,"line":71},[50,10983,973],{"class":196},[50,10985,6215],{"class":203},[50,10987,10988],{"class":207},"(res.url, { ",[50,10990,10991],{"class":1853},"\u002F* re-run options *\u002F",[50,10993,6134],{"class":207},[50,10995,10996],{"class":52,"line":77},[50,10997,1153],{"class":207},[1349,10999,11001],{"id":11000},"layer-4-retry-with-exponential-backoff","Layer 4: retry with exponential backoff",[11,11003,11004],{},"For idempotent requests (GET, HEAD, most PUTs, DELETEs), a small retry helper absorbs transient network failures:",[41,11006,11008],{"className":187,"code":11007,"language":189,"meta":46,"style":46},"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",[15,11009,11010,11070,11084,11114,11120,11130,11139,11149,11193,11197,11201,11209],{"__ignoreMap":46},[50,11011,11012,11014,11016,11019,11021,11023,11025,11027,11029,11031,11033,11035,11037,11039,11041,11044,11046,11048,11050,11053,11055,11058,11060,11062,11064,11066,11068],{"class":52,"line":53},[50,11013,4228],{"class":196},[50,11015,4231],{"class":196},[50,11017,11018],{"class":203}," withRetry",[50,11020,208],{"class":207},[50,11022,211],{"class":203},[50,11024,4670],{"class":207},[50,11026,10714],{"class":203},[50,11028,230],{"class":196},[50,11030,359],{"class":207},[50,11032,362],{"class":196},[50,11034,4242],{"class":203},[50,11036,208],{"class":207},[50,11038,211],{"class":203},[50,11040,1422],{"class":207},[50,11042,11043],{"class":226},"attempts",[50,11045,214],{"class":196},[50,11047,7972],{"class":217},[50,11049,284],{"class":207},[50,11051,11052],{"class":226},"baseDelayMs",[50,11054,214],{"class":196},[50,11056,11057],{"class":217}," 200",[50,11059,1643],{"class":207},[50,11061,230],{"class":196},[50,11063,4242],{"class":203},[50,11065,208],{"class":207},[50,11067,211],{"class":203},[50,11069,221],{"class":207},[50,11071,11072,11075,11078,11080,11082],{"class":52,"line":59},[50,11073,11074],{"class":196},"  let",[50,11076,11077],{"class":207}," lastError",[50,11079,230],{"class":196},[50,11081,218],{"class":217},[50,11083,325],{"class":207},[50,11085,11086,11088,11090,11093,11096,11098,11100,11103,11106,11109,11112],{"class":52,"line":65},[50,11087,6971],{"class":196},[50,11089,420],{"class":207},[50,11091,11092],{"class":196},"let",[50,11094,11095],{"class":207}," i ",[50,11097,639],{"class":196},[50,11099,2433],{"class":217},[50,11101,11102],{"class":207},"; i ",[50,11104,11105],{"class":196},"\u003C=",[50,11107,11108],{"class":207}," attempts; i",[50,11110,11111],{"class":196},"++",[50,11113,1718],{"class":207},[50,11115,11116,11118],{"class":52,"line":71},[50,11117,6322],{"class":196},[50,11119,968],{"class":207},[50,11121,11122,11124,11126,11128],{"class":52,"line":77},[50,11123,1723],{"class":196},[50,11125,4325],{"class":196},[50,11127,4278],{"class":203},[50,11129,1999],{"class":207},[50,11131,11132,11134,11136],{"class":52,"line":83},[50,11133,6437],{"class":207},[50,11135,6440],{"class":196},[50,11137,11138],{"class":207}," (e) {\n",[50,11140,11141,11144,11146],{"class":52,"line":89},[50,11142,11143],{"class":207},"      lastError ",[50,11145,639],{"class":196},[50,11147,11148],{"class":207}," e;\n",[50,11150,11151,11153,11156,11158,11161,11163,11165,11167,11169,11172,11174,11176,11179,11182,11185,11187,11190],{"class":52,"line":95},[50,11152,6352],{"class":196},[50,11154,11155],{"class":207}," (i ",[50,11157,208],{"class":196},[50,11159,11160],{"class":207}," attempts) ",[50,11162,6534],{"class":196},[50,11164,2279],{"class":196},[50,11166,4242],{"class":217},[50,11168,1676],{"class":207},[50,11170,11171],{"class":226},"r",[50,11173,440],{"class":207},[50,11175,362],{"class":196},[50,11177,11178],{"class":203}," setTimeout",[50,11180,11181],{"class":207},"(r, baseDelayMs ",[50,11183,11184],{"class":196},"*",[50,11186,7972],{"class":217},[50,11188,11189],{"class":196}," **",[50,11191,11192],{"class":207}," i));\n",[50,11194,11195],{"class":52,"line":101},[50,11196,1745],{"class":207},[50,11198,11199],{"class":52,"line":107},[50,11200,110],{"class":207},[50,11202,11203,11206],{"class":52,"line":113},[50,11204,11205],{"class":196},"  throw",[50,11207,11208],{"class":207}," lastError;\n",[50,11210,11211],{"class":52,"line":120},[50,11212,170],{"class":207},[11,11214,11215,11216,11219],{},"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 (",[15,11217,11218],{},"['GET', 'HEAD', 'PUT', 'DELETE']",") inside the wrapper is usually how this gets enforced.",[1349,11221,11223],{"id":11222},"what-you-end-up-with","What you end up with",[11,11225,11226],{},"After those four layers, the consumer surface looks like:",[41,11228,11230],{"className":187,"code":11229,"language":189,"meta":46,"style":46},"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",[15,11231,11232,11246,11256,11266,11270,11274,11290,11328,11332,11336],{"__ignoreMap":46},[50,11233,11234,11236,11239,11241,11243],{"class":52,"line":53},[50,11235,952],{"class":196},[50,11237,11238],{"class":217}," api",[50,11240,214],{"class":196},[50,11242,9764],{"class":203},[50,11244,11245],{"class":207},"({\n",[50,11247,11248,11251,11254],{"class":52,"line":59},[50,11249,11250],{"class":207},"  baseURL: ",[50,11252,11253],{"class":311},"'https:\u002F\u002Fapi.example.com'",[50,11255,999],{"class":207},[50,11257,11258,11261,11264],{"class":52,"line":65},[50,11259,11260],{"class":207},"  defaultHeaders: { Accept: ",[50,11262,11263],{"class":311},"'application\u002Fjson'",[50,11265,1085],{"class":207},[50,11267,11268],{"class":52,"line":71},[50,11269,1153],{"class":207},[50,11271,11272],{"class":52,"line":77},[50,11273,117],{"emptyLinePlaceholder":116},[50,11275,11276,11278,11280,11282,11284,11286,11288],{"class":52,"line":83},[50,11277,10864],{"class":207},[50,11279,10707],{"class":203},[50,11281,1676],{"class":207},[50,11283,10314],{"class":226},[50,11285,440],{"class":207},[50,11287,362],{"class":196},[50,11289,968],{"class":207},[50,11291,11292,11294,11296,11298,11300,11302,11304,11306,11308,11310,11312,11314,11316,11318,11320,11322,11324,11326],{"class":52,"line":89},[50,11293,10881],{"class":207},[50,11295,639],{"class":196},[50,11297,2856],{"class":207},[50,11299,3198],{"class":196},[50,11301,10890],{"class":207},[50,11303,1537],{"class":196},[50,11305,276],{"class":203},[50,11307,208],{"class":207},[50,11309,281],{"class":217},[50,11311,284],{"class":207},[50,11313,281],{"class":217},[50,11315,10905],{"class":207},[50,11317,10908],{"class":311},[50,11319,10911],{"class":207},[50,11321,181],{"class":311},[50,11323,5666],{"class":207},[50,11325,2049],{"class":311},[50,11327,2876],{"class":207},[50,11329,11330],{"class":52,"line":95},[50,11331,1153],{"class":207},[50,11333,11334],{"class":52,"line":101},[50,11335,117],{"emptyLinePlaceholder":116},[50,11337,11338,11340,11343,11345,11347,11349,11351,11354,11357,11360],{"class":52,"line":107},[50,11339,952],{"class":196},[50,11341,11342],{"class":217}," users",[50,11344,214],{"class":196},[50,11346,4325],{"class":196},[50,11348,11238],{"class":203},[50,11350,208],{"class":207},[50,11352,11353],{"class":203},"User",[50,11355,11356],{"class":207},"[]>(",[50,11358,11359],{"class":311},"'\u002Fusers'",[50,11361,1971],{"class":207},[11,11363,11364,11365,181],{},"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 ",[15,11366,6704],{},[11,11368,11369],{},"Which is exactly where the upload-progress gap lives.",[30,11371,11373,11374,11376],{"id":11372},"why-fetch-cant-show-upload-progress","Why ",[15,11375,6704],{}," can't show upload progress",[11,11378,11379,11380,11382],{},"Download progress with ",[15,11381,6704],{}," works, but awkwardly. You read the response body as a stream:",[41,11384,11386],{"className":187,"code":11385,"language":189,"meta":46,"style":46},"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",[15,11387,11388,11407,11438,11451,11472,11483,11510,11521,11532,11540],{"__ignoreMap":46},[50,11389,11390,11392,11394,11396,11398,11400,11402,11405],{"class":52,"line":53},[50,11391,952],{"class":196},[50,11393,6208],{"class":217},[50,11395,214],{"class":196},[50,11397,4325],{"class":196},[50,11399,6215],{"class":203},[50,11401,1073],{"class":207},[50,11403,11404],{"class":311},"'\u002Fbig.zip'",[50,11406,1971],{"class":207},[50,11408,11409,11411,11414,11416,11419,11422,11424,11426,11429,11432,11434,11436],{"class":52,"line":59},[50,11410,952],{"class":196},[50,11412,11413],{"class":217}," total",[50,11415,214],{"class":196},[50,11417,11418],{"class":203}," Number",[50,11420,11421],{"class":207},"(res.headers.",[50,11423,7028],{"class":203},[50,11425,1073],{"class":207},[50,11427,11428],{"class":311},"'content-length'",[50,11430,11431],{"class":207},")) ",[50,11433,4757],{"class":196},[50,11435,4667],{"class":217},[50,11437,325],{"class":207},[50,11439,11440,11442,11445,11447,11449],{"class":52,"line":65},[50,11441,11092],{"class":196},[50,11443,11444],{"class":207}," loaded ",[50,11446,639],{"class":196},[50,11448,2433],{"class":217},[50,11450,325],{"class":207},[50,11452,11453,11455,11458,11460,11463,11465,11467,11470],{"class":52,"line":71},[50,11454,952],{"class":196},[50,11456,11457],{"class":217}," reader",[50,11459,214],{"class":196},[50,11461,11462],{"class":207}," res.body",[50,11464,1275],{"class":196},[50,11466,181],{"class":207},[50,11468,11469],{"class":203},"getReader",[50,11471,1999],{"class":207},[50,11473,11474,11477,11479,11481],{"class":52,"line":77},[50,11475,11476],{"class":196},"while",[50,11478,420],{"class":207},[50,11480,3493],{"class":217},[50,11482,1718],{"class":207},[50,11484,11485,11487,11489,11491,11493,11496,11498,11500,11502,11505,11508],{"class":52,"line":83},[50,11486,1439],{"class":196},[50,11488,2856],{"class":207},[50,11490,5666],{"class":217},[50,11492,284],{"class":207},[50,11494,11495],{"class":217},"done",[50,11497,8425],{"class":207},[50,11499,639],{"class":196},[50,11501,4325],{"class":196},[50,11503,11504],{"class":207}," reader.",[50,11506,11507],{"class":203},"read",[50,11509,1999],{"class":207},[50,11511,11512,11514,11517,11519],{"class":52,"line":89},[50,11513,1270],{"class":196},[50,11515,11516],{"class":207}," (done) ",[50,11518,7470],{"class":196},[50,11520,325],{"class":207},[50,11522,11523,11526,11529],{"class":52,"line":95},[50,11524,11525],{"class":207},"  loaded ",[50,11527,11528],{"class":196},"+=",[50,11530,11531],{"class":207}," value.byteLength;\n",[50,11533,11534,11537],{"class":52,"line":101},[50,11535,11536],{"class":203},"  onProgress",[50,11538,11539],{"class":207},"(loaded, total);\n",[50,11541,11542],{"class":52,"line":107},[50,11543,170],{"class":207},[11,11545,11546,11547,11549],{},"That's fine. You do have to keep the chunks around yourself (or pipe them into a new ",[15,11548,9389],{}," if you want to hand them off), but the primitives are there.",[11,11551,11552,11553,11555,11556,11558,11559,11562,11563,11565,11566,11569],{},"For uploads, the missing piece is that the ",[15,11554,9386],{}," body is consumed by the browser's network stack, not by your code. You can pass a ",[15,11557,9405],{},", but there's no event that fires as the browser drains it. Some Chromium versions support ",[15,11560,11561],{},"Request.duplex: 'half'"," which opens the door to upload streaming, but there's still no ",[15,11564,8338],{}," event — you'd have to instrument your own stream's ",[15,11567,11568],{},"pull()"," to count bytes, and even then you're measuring what the stream emits, not what the socket has sent.",[11,11571,11572],{},"In practice, if you want a progress bar for uploads in 2026, you use XHR.",[30,11574,11576],{"id":11575},"the-five-xhr-events-you-actually-care-about","The five XHR events you actually care about",[11,11578,11579],{},"Every XHR fires a predictable cluster of events. The ones that matter for progress UIs are:",[41,11581,11583],{"className":187,"code":11582,"language":189,"meta":46,"style":46},"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",[15,11584,11585,11616,11645,11666,11687,11707],{"__ignoreMap":46},[50,11586,11587,11590,11593,11595,11597,11600,11602,11605,11607,11609,11611,11614],{"class":52,"line":53},[50,11588,11589],{"class":207},"xhr.upload.",[50,11591,11592],{"class":203},"onprogress",[50,11594,214],{"class":196},[50,11596,420],{"class":207},[50,11598,11599],{"class":226},"e",[50,11601,230],{"class":196},[50,11603,11604],{"class":203}," ProgressEvent",[50,11606,440],{"class":207},[50,11608,362],{"class":196},[50,11610,2856],{"class":207},[50,11612,11613],{"class":1853},"\u002F* upload bytes *\u002F",[50,11615,2876],{"class":207},[50,11617,11618,11621,11623,11626,11628,11630,11632,11634,11636,11638,11640,11643],{"class":52,"line":59},[50,11619,11620],{"class":207},"xhr.",[50,11622,11592],{"class":203},[50,11624,11625],{"class":196},"        =",[50,11627,420],{"class":207},[50,11629,11599],{"class":226},[50,11631,230],{"class":196},[50,11633,11604],{"class":203},[50,11635,440],{"class":207},[50,11637,362],{"class":196},[50,11639,2856],{"class":207},[50,11641,11642],{"class":1853},"\u002F* download bytes *\u002F",[50,11644,2876],{"class":207},[50,11646,11647,11649,11652,11655,11657,11659,11661,11664],{"class":52,"line":65},[50,11648,11620],{"class":207},[50,11650,11651],{"class":203},"onload",[50,11653,11654],{"class":196},"            =",[50,11656,359],{"class":207},[50,11658,362],{"class":196},[50,11660,2856],{"class":207},[50,11662,11663],{"class":1853},"\u002F* request completed successfully *\u002F",[50,11665,2876],{"class":207},[50,11667,11668,11670,11673,11676,11678,11680,11682,11685],{"class":52,"line":71},[50,11669,11620],{"class":207},[50,11671,11672],{"class":203},"onerror",[50,11674,11675],{"class":196},"           =",[50,11677,359],{"class":207},[50,11679,362],{"class":196},[50,11681,2856],{"class":207},[50,11683,11684],{"class":1853},"\u002F* network error *\u002F",[50,11686,2876],{"class":207},[50,11688,11689,11691,11694,11696,11698,11700,11702,11705],{"class":52,"line":77},[50,11690,11620],{"class":207},[50,11692,11693],{"class":203},"onabort",[50,11695,11675],{"class":196},[50,11697,359],{"class":207},[50,11699,362],{"class":196},[50,11701,2856],{"class":207},[50,11703,11704],{"class":1853},"\u002F* xhr.abort() was called *\u002F",[50,11706,2876],{"class":207},[50,11708,11709,11711,11714,11717,11719,11721,11723,11726],{"class":52,"line":83},[50,11710,11620],{"class":207},[50,11712,11713],{"class":203},"ontimeout",[50,11715,11716],{"class":196},"         =",[50,11718,359],{"class":207},[50,11720,362],{"class":196},[50,11722,2856],{"class":207},[50,11724,11725],{"class":1853},"\u002F* xhr.timeout exceeded *\u002F",[50,11727,2876],{"class":207},[11,11729,11730],{},"Two things are easy to miss.",[11,11732,11733,11734,11737,11738,11741,11742,11744,11745,11748,11749,11751],{},"First, ",[15,11735,11736],{},"xhr.upload"," is a separate ",[15,11739,11740],{},"XMLHttpRequestUpload"," object with its own event target. It fires ",[15,11743,8338],{}," as the request body is sent; ",[15,11746,11747],{},"xhr"," itself fires ",[15,11750,8338],{}," as the response body is received. They're the two phases of the same request and they use the same event shape.",[11,11753,11754,11755,11758,11759,11762,11763,181],{},"Second, ",[15,11756,11757],{},"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 ",[15,11760,11761],{},"xhr.status"," yourself inside ",[15,11764,11651],{},[11,11766,11767,11768,284,11771,284,11774,11777],{},"There are other events (",[15,11769,11770],{},"loadstart",[15,11772,11773],{},"loadend",[15,11775,11776],{},"readystatechange",") but for a progress UI the five above cover everything you need.",[30,11779,892,11781,11784,11785],{"id":11780},"the-progressevent-shape-and-lengthcomputable",[15,11782,11783],{},"ProgressEvent"," shape, and ",[15,11786,11787],{},"lengthComputable",[11,11789,11790,11791,11793,11794,11796],{},"Both ",[15,11792,11592],{}," callbacks receive a ",[15,11795,11783],{}," with three fields:",[41,11798,11800],{"className":187,"code":11799,"language":189,"meta":46,"style":46},"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",[15,11801,11802,11811,11822,11837,11852],{"__ignoreMap":46},[50,11803,11804,11807,11809],{"class":52,"line":53},[50,11805,11806],{"class":196},"interface",[50,11808,11604],{"class":203},[50,11810,968],{"class":207},[50,11812,11813,11816,11818,11820],{"class":52,"line":59},[50,11814,11815],{"class":226},"  lengthComputable",[50,11817,230],{"class":196},[50,11819,335],{"class":217},[50,11821,325],{"class":207},[50,11823,11824,11827,11829,11831,11834],{"class":52,"line":65},[50,11825,11826],{"class":226},"  loaded",[50,11828,230],{"class":196},[50,11830,445],{"class":217},[50,11832,11833],{"class":207},";   ",[50,11835,11836],{"class":1853},"\u002F\u002F bytes transferred so far\n",[50,11838,11839,11842,11844,11846,11849],{"class":52,"line":71},[50,11840,11841],{"class":226},"  total",[50,11843,230],{"class":196},[50,11845,445],{"class":217},[50,11847,11848],{"class":207},";    ",[50,11850,11851],{"class":1853},"\u002F\u002F bytes expected — ONLY valid when lengthComputable is true\n",[50,11853,11854],{"class":52,"line":77},[50,11855,170],{"class":207},[11,11857,11858,11860,11861,11863,11864,11867,11868,11870,11871,11874,11875,11877,11878,11880,11881,1390,11883,11885],{},[15,11859,11787],{}," is the field most first-time implementations forget. It's ",[15,11862,4639],{}," when the server sent a chunked response without a ",[15,11865,11866],{},"Content-Length"," header, and it's ",[15,11869,4639],{}," during uploads where the body is a stream of unknown length. When it's false, ",[15,11872,11873],{},"total"," is ",[15,11876,5065],{}," — not \"not provided,\" not ",[15,11879,774],{},", just the number zero — and dividing by it gives you ",[15,11882,5888],{},[15,11884,5892],{}," in your progress bar.",[11,11887,11888],{},"Wrap the normalization in one place:",[41,11890,11892],{"className":187,"code":11891,"language":189,"meta":46,"style":46},"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",[15,11893,11894,11926,11948,11984,11991],{"__ignoreMap":46},[50,11895,11896,11898,11901,11903,11906,11908,11911,11913,11916,11918,11920,11922,11924],{"class":52,"line":53},[50,11897,1895],{"class":196},[50,11899,11900],{"class":203}," normaliseProgress",[50,11902,1073],{"class":207},[50,11904,11905],{"class":226},"phase",[50,11907,230],{"class":196},[50,11909,11910],{"class":311}," 'upload'",[50,11912,236],{"class":196},[50,11914,11915],{"class":311}," 'download'",[50,11917,284],{"class":207},[50,11919,11599],{"class":226},[50,11921,230],{"class":196},[50,11923,11604],{"class":203},[50,11925,1718],{"class":207},[50,11927,11928,11930,11932,11934,11937,11939,11942,11944,11946],{"class":52,"line":59},[50,11929,1439],{"class":196},[50,11931,11413],{"class":217},[50,11933,214],{"class":196},[50,11935,11936],{"class":207}," e.lengthComputable ",[50,11938,987],{"class":196},[50,11940,11941],{"class":207}," e.total ",[50,11943,230],{"class":196},[50,11945,4667],{"class":217},[50,11947,325],{"class":207},[50,11949,11950,11952,11955,11957,11960,11962,11964,11967,11969,11971,11974,11976,11978,11980,11982],{"class":52,"line":65},[50,11951,1439],{"class":196},[50,11953,11954],{"class":217}," ratio",[50,11956,214],{"class":196},[50,11958,11959],{"class":207}," total ",[50,11961,1705],{"class":196},[50,11963,11959],{"class":207},[50,11965,11966],{"class":196},">",[50,11968,2433],{"class":217},[50,11970,1303],{"class":196},[50,11972,11973],{"class":207}," e.loaded ",[50,11975,7205],{"class":196},[50,11977,11959],{"class":207},[50,11979,230],{"class":196},[50,11981,4667],{"class":217},[50,11983,325],{"class":207},[50,11985,11986,11988],{"class":52,"line":71},[50,11987,973],{"class":196},[50,11989,11990],{"class":207}," { phase, loaded: e.loaded, total, ratio };\n",[50,11992,11993],{"class":52,"line":77},[50,11994,170],{"class":207},[11,11996,11997,11999,12000,12002],{},[15,11998,4673],{}," 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 ",[15,12001,5888],{}," leak out of your transport.",[30,12004,12006],{"id":12005},"a-minimal-upload-progress-example","A minimal upload-progress example",[11,12008,12009],{},"Here's the smallest useful upload with progress, showing the API surface without a wrapper:",[41,12011,12013],{"className":187,"code":12012,"language":189,"meta":46,"style":46},"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",[15,12014,12015,12026,12038,12072,12086,12115,12131,12155,12159,12178,12198,12206,12210,12214,12228,12256,12286,12290,12318,12351,12355,12371,12387,12397,12401],{"__ignoreMap":46},[50,12016,12017,12019,12021,12024],{"class":52,"line":53},[50,12018,4228],{"class":196},[50,12020,4231],{"class":196},[50,12022,12023],{"class":203}," uploadWithProgress",[50,12025,979],{"class":207},[50,12027,12028,12031,12033,12036],{"class":52,"line":59},[50,12029,12030],{"class":226},"  file",[50,12032,230],{"class":196},[50,12034,12035],{"class":203}," File",[50,12037,999],{"class":207},[50,12039,12040,12042,12044,12046,12049,12051,12053,12055,12057,12059,12061,12063,12065,12067,12069],{"class":52,"line":65},[50,12041,11536],{"class":203},[50,12043,230],{"class":196},[50,12045,420],{"class":207},[50,12047,12048],{"class":226},"loaded",[50,12050,230],{"class":196},[50,12052,445],{"class":217},[50,12054,284],{"class":207},[50,12056,11873],{"class":226},[50,12058,230],{"class":196},[50,12060,445],{"class":217},[50,12062,236],{"class":196},[50,12064,4667],{"class":217},[50,12066,440],{"class":207},[50,12068,362],{"class":196},[50,12070,12071],{"class":217}," void\n",[50,12073,12074,12076,12078,12080,12082,12084],{"class":52,"line":71},[50,12075,1643],{"class":207},[50,12077,230],{"class":196},[50,12079,4242],{"class":203},[50,12081,208],{"class":207},[50,12083,5906],{"class":217},[50,12085,221],{"class":207},[50,12087,12088,12090,12092,12094,12096,12098,12101,12104,12106,12109,12111,12113],{"class":52,"line":77},[50,12089,10974],{"class":196},[50,12091,2279],{"class":196},[50,12093,4242],{"class":217},[50,12095,208],{"class":207},[50,12097,5906],{"class":217},[50,12099,12100],{"class":207},">((",[50,12102,12103],{"class":226},"resolve",[50,12105,284],{"class":207},[50,12107,12108],{"class":226},"reject",[50,12110,440],{"class":207},[50,12112,362],{"class":196},[50,12114,968],{"class":207},[50,12116,12117,12119,12122,12124,12126,12129],{"class":52,"line":83},[50,12118,3224],{"class":196},[50,12120,12121],{"class":217}," xhr",[50,12123,214],{"class":196},[50,12125,2279],{"class":196},[50,12127,12128],{"class":203}," XMLHttpRequest",[50,12130,1999],{"class":207},[50,12132,12133,12136,12139,12141,12144,12146,12149,12151,12153],{"class":52,"line":89},[50,12134,12135],{"class":207},"    xhr.",[50,12137,12138],{"class":203},"open",[50,12140,1073],{"class":207},[50,12142,12143],{"class":311},"'POST'",[50,12145,284],{"class":207},[50,12147,12148],{"class":311},"'\u002Fupload'",[50,12150,284],{"class":207},[50,12152,3493],{"class":217},[50,12154,1971],{"class":207},[50,12156,12157],{"class":52,"line":95},[50,12158,117],{"emptyLinePlaceholder":116},[50,12160,12161,12164,12166,12168,12170,12172,12174,12176],{"class":52,"line":101},[50,12162,12163],{"class":207},"    xhr.upload.",[50,12165,11592],{"class":203},[50,12167,214],{"class":196},[50,12169,420],{"class":207},[50,12171,11599],{"class":226},[50,12173,440],{"class":207},[50,12175,362],{"class":196},[50,12177,968],{"class":207},[50,12179,12180,12182,12184,12186,12188,12190,12192,12194,12196],{"class":52,"line":107},[50,12181,6329],{"class":196},[50,12183,11413],{"class":217},[50,12185,214],{"class":196},[50,12187,11936],{"class":207},[50,12189,987],{"class":196},[50,12191,11941],{"class":207},[50,12193,230],{"class":196},[50,12195,4667],{"class":217},[50,12197,325],{"class":207},[50,12199,12200,12203],{"class":52,"line":113},[50,12201,12202],{"class":203},"      onProgress",[50,12204,12205],{"class":207},"(e.loaded, total);\n",[50,12207,12208],{"class":52,"line":120},[50,12209,159],{"class":207},[50,12211,12212],{"class":52,"line":126},[50,12213,117],{"emptyLinePlaceholder":116},[50,12215,12216,12218,12220,12222,12224,12226],{"class":52,"line":132},[50,12217,12135],{"class":207},[50,12219,11651],{"class":203},[50,12221,214],{"class":196},[50,12223,359],{"class":207},[50,12225,362],{"class":196},[50,12227,968],{"class":207},[50,12229,12230,12232,12235,12238,12240,12242,12245,12247,12250,12252,12254],{"class":52,"line":138},[50,12231,6352],{"class":196},[50,12233,12234],{"class":207}," (xhr.status ",[50,12236,12237],{"class":196},">=",[50,12239,11057],{"class":217},[50,12241,2423],{"class":196},[50,12243,12244],{"class":207}," xhr.status ",[50,12246,208],{"class":196},[50,12248,12249],{"class":217}," 300",[50,12251,440],{"class":207},[50,12253,12103],{"class":203},[50,12255,1999],{"class":207},[50,12257,12258,12261,12264,12266,12268,12270,12272,12275,12277,12279,12281,12283],{"class":52,"line":144},[50,12259,12260],{"class":196},"      else",[50,12262,12263],{"class":203}," reject",[50,12265,1073],{"class":207},[50,12267,5184],{"class":196},[50,12269,6503],{"class":203},[50,12271,1073],{"class":207},[50,12273,12274],{"class":311},"`HTTP ${",[50,12276,11747],{"class":207},[50,12278,181],{"class":311},[50,12280,6516],{"class":207},[50,12282,2049],{"class":311},[50,12284,12285],{"class":207},"));\n",[50,12287,12288],{"class":52,"line":150},[50,12289,159],{"class":207},[50,12291,12292,12294,12296,12298,12300,12302,12304,12306,12308,12311,12313,12316],{"class":52,"line":156},[50,12293,12135],{"class":207},[50,12295,11672],{"class":203},[50,12297,214],{"class":196},[50,12299,359],{"class":207},[50,12301,362],{"class":196},[50,12303,12263],{"class":203},[50,12305,1073],{"class":207},[50,12307,5184],{"class":196},[50,12309,12310],{"class":203}," TypeError",[50,12312,1073],{"class":207},[50,12314,12315],{"class":311},"'Network error'",[50,12317,12285],{"class":207},[50,12319,12320,12322,12324,12326,12328,12330,12332,12334,12336,12339,12341,12344,12346,12349],{"class":52,"line":162},[50,12321,12135],{"class":207},[50,12323,11693],{"class":203},[50,12325,214],{"class":196},[50,12327,359],{"class":207},[50,12329,362],{"class":196},[50,12331,12263],{"class":203},[50,12333,1073],{"class":207},[50,12335,5184],{"class":196},[50,12337,12338],{"class":203}," DOMException",[50,12340,1073],{"class":207},[50,12342,12343],{"class":311},"'Aborted'",[50,12345,284],{"class":207},[50,12347,12348],{"class":311},"'AbortError'",[50,12350,12285],{"class":207},[50,12352,12353],{"class":52,"line":167},[50,12354,117],{"emptyLinePlaceholder":116},[50,12356,12357,12359,12362,12364,12366,12369],{"class":52,"line":3263},[50,12358,3224],{"class":196},[50,12360,12361],{"class":217}," form",[50,12363,214],{"class":196},[50,12365,2279],{"class":196},[50,12367,12368],{"class":203}," FormData",[50,12370,1999],{"class":207},[50,12372,12373,12376,12379,12381,12384],{"class":52,"line":3275},[50,12374,12375],{"class":207},"    form.",[50,12377,12378],{"class":203},"append",[50,12380,1073],{"class":207},[50,12382,12383],{"class":311},"'file'",[50,12385,12386],{"class":207},", file);\n",[50,12388,12389,12391,12394],{"class":52,"line":6553},[50,12390,12135],{"class":207},[50,12392,12393],{"class":203},"send",[50,12395,12396],{"class":207},"(form);\n",[50,12398,12399],{"class":52,"line":6569},[50,12400,1499],{"class":207},[50,12402,12403],{"class":52,"line":6592},[50,12404,170],{"class":207},[11,12406,12407],{},"Three things worth pointing out:",[2163,12409,12410,12427,12437],{},[2166,12411,12412,12422,12423,12426],{},[2169,12413,12414,12415,12418,12419,181],{},"Don't set ",[15,12416,12417],{},"Content-Type"," when sending ",[15,12420,12421],{},"FormData"," The browser needs to pick its own boundary string for multipart encoding. If you set ",[15,12424,12425],{},"Content-Type: multipart\u002Fform-data"," manually, you'll override it without the boundary, and the server will fail to parse the body.",[2166,12428,12429,2185,12434,12436],{},[2169,12430,12431,12433],{},[15,12432,11761],{}," check is explicit.",[15,12435,11651],{}," 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.",[2166,12438,12439,12445,12446,12449],{},[2169,12440,12441,12444],{},[15,12442,12443],{},"new Promise"," is the adapter."," XHR is event-based; the rest of your code is ",[15,12447,12448],{},"async\u002Fawait","-shaped. Wrapping it once in a Promise keeps the awkwardness local.",[30,12451,12453],{"id":12452},"a-minimal-download-progress-example","A minimal download-progress example",[11,12455,12456,12457,230],{},"For downloads, the same shape, but on ",[15,12458,12459],{},"xhr.onprogress",[41,12461,12463],{"className":187,"code":12462,"language":189,"meta":46,"style":46},"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",[15,12464,12465,12476,12487,12519,12534,12560,12574,12592,12604,12608,12626,12646,12652,12656,12660,12674,12694,12709,12718,12743,12747,12751,12777,12781,12789,12793],{"__ignoreMap":46},[50,12466,12467,12469,12471,12474],{"class":52,"line":53},[50,12468,4228],{"class":196},[50,12470,4231],{"class":196},[50,12472,12473],{"class":203}," downloadWithProgress",[50,12475,979],{"class":207},[50,12477,12478,12481,12483,12485],{"class":52,"line":59},[50,12479,12480],{"class":226},"  url",[50,12482,230],{"class":196},[50,12484,233],{"class":217},[50,12486,999],{"class":207},[50,12488,12489,12491,12493,12495,12497,12499,12501,12503,12505,12507,12509,12511,12513,12515,12517],{"class":52,"line":65},[50,12490,11536],{"class":203},[50,12492,230],{"class":196},[50,12494,420],{"class":207},[50,12496,12048],{"class":226},[50,12498,230],{"class":196},[50,12500,445],{"class":217},[50,12502,284],{"class":207},[50,12504,11873],{"class":226},[50,12506,230],{"class":196},[50,12508,445],{"class":217},[50,12510,236],{"class":196},[50,12512,4667],{"class":217},[50,12514,440],{"class":207},[50,12516,362],{"class":196},[50,12518,12071],{"class":217},[50,12520,12521,12523,12525,12527,12529,12532],{"class":52,"line":71},[50,12522,1643],{"class":207},[50,12524,230],{"class":196},[50,12526,4242],{"class":203},[50,12528,208],{"class":207},[50,12530,12531],{"class":203},"Blob",[50,12533,221],{"class":207},[50,12535,12536,12538,12540,12542,12544,12546,12548,12550,12552,12554,12556,12558],{"class":52,"line":77},[50,12537,973],{"class":196},[50,12539,2279],{"class":196},[50,12541,4242],{"class":217},[50,12543,208],{"class":207},[50,12545,12531],{"class":203},[50,12547,12100],{"class":207},[50,12549,12103],{"class":226},[50,12551,284],{"class":207},[50,12553,12108],{"class":226},[50,12555,440],{"class":207},[50,12557,362],{"class":196},[50,12559,968],{"class":207},[50,12561,12562,12564,12566,12568,12570,12572],{"class":52,"line":83},[50,12563,3224],{"class":196},[50,12565,12121],{"class":217},[50,12567,214],{"class":196},[50,12569,2279],{"class":196},[50,12571,12128],{"class":203},[50,12573,1999],{"class":207},[50,12575,12576,12578,12580,12582,12585,12588,12590],{"class":52,"line":89},[50,12577,12135],{"class":207},[50,12579,12138],{"class":203},[50,12581,1073],{"class":207},[50,12583,12584],{"class":311},"'GET'",[50,12586,12587],{"class":207},", url, ",[50,12589,3493],{"class":217},[50,12591,1971],{"class":207},[50,12593,12594,12597,12599,12602],{"class":52,"line":95},[50,12595,12596],{"class":207},"    xhr.responseType ",[50,12598,639],{"class":196},[50,12600,12601],{"class":311}," 'blob'",[50,12603,325],{"class":207},[50,12605,12606],{"class":52,"line":101},[50,12607,117],{"emptyLinePlaceholder":116},[50,12609,12610,12612,12614,12616,12618,12620,12622,12624],{"class":52,"line":107},[50,12611,12135],{"class":207},[50,12613,11592],{"class":203},[50,12615,214],{"class":196},[50,12617,420],{"class":207},[50,12619,11599],{"class":226},[50,12621,440],{"class":207},[50,12623,362],{"class":196},[50,12625,968],{"class":207},[50,12627,12628,12630,12632,12634,12636,12638,12640,12642,12644],{"class":52,"line":113},[50,12629,6329],{"class":196},[50,12631,11413],{"class":217},[50,12633,214],{"class":196},[50,12635,11936],{"class":207},[50,12637,987],{"class":196},[50,12639,11941],{"class":207},[50,12641,230],{"class":196},[50,12643,4667],{"class":217},[50,12645,325],{"class":207},[50,12647,12648,12650],{"class":52,"line":120},[50,12649,12202],{"class":203},[50,12651,12205],{"class":207},[50,12653,12654],{"class":52,"line":126},[50,12655,159],{"class":207},[50,12657,12658],{"class":52,"line":132},[50,12659,117],{"emptyLinePlaceholder":116},[50,12661,12662,12664,12666,12668,12670,12672],{"class":52,"line":138},[50,12663,12135],{"class":207},[50,12665,11651],{"class":203},[50,12667,214],{"class":196},[50,12669,359],{"class":207},[50,12671,362],{"class":196},[50,12673,968],{"class":207},[50,12675,12676,12678,12680,12682,12684,12686,12688,12690,12692],{"class":52,"line":144},[50,12677,6352],{"class":196},[50,12679,12234],{"class":207},[50,12681,12237],{"class":196},[50,12683,11057],{"class":217},[50,12685,2423],{"class":196},[50,12687,12244],{"class":207},[50,12689,208],{"class":196},[50,12691,12249],{"class":217},[50,12693,1718],{"class":207},[50,12695,12696,12699,12702,12704,12707],{"class":52,"line":150},[50,12697,12698],{"class":203},"        resolve",[50,12700,12701],{"class":207},"(xhr.response ",[50,12703,1537],{"class":196},[50,12705,12706],{"class":203}," Blob",[50,12708,1971],{"class":207},[50,12710,12711,12714,12716],{"class":52,"line":156},[50,12712,12713],{"class":207},"      } ",[50,12715,2946],{"class":196},[50,12717,968],{"class":207},[50,12719,12720,12723,12725,12727,12729,12731,12733,12735,12737,12739,12741],{"class":52,"line":162},[50,12721,12722],{"class":203},"        reject",[50,12724,1073],{"class":207},[50,12726,5184],{"class":196},[50,12728,6503],{"class":203},[50,12730,1073],{"class":207},[50,12732,12274],{"class":311},[50,12734,11747],{"class":207},[50,12736,181],{"class":311},[50,12738,6516],{"class":207},[50,12740,2049],{"class":311},[50,12742,12285],{"class":207},[50,12744,12745],{"class":52,"line":167},[50,12746,6432],{"class":207},[50,12748,12749],{"class":52,"line":3263},[50,12750,159],{"class":207},[50,12752,12753,12755,12757,12759,12761,12763,12765,12767,12769,12771,12773,12775],{"class":52,"line":3275},[50,12754,12135],{"class":207},[50,12756,11672],{"class":203},[50,12758,214],{"class":196},[50,12760,359],{"class":207},[50,12762,362],{"class":196},[50,12764,12263],{"class":203},[50,12766,1073],{"class":207},[50,12768,5184],{"class":196},[50,12770,12310],{"class":203},[50,12772,1073],{"class":207},[50,12774,12315],{"class":311},[50,12776,12285],{"class":207},[50,12778,12779],{"class":52,"line":6553},[50,12780,117],{"emptyLinePlaceholder":116},[50,12782,12783,12785,12787],{"class":52,"line":6569},[50,12784,12135],{"class":207},[50,12786,12393],{"class":203},[50,12788,1999],{"class":207},[50,12790,12791],{"class":52,"line":6592},[50,12792,1499],{"class":207},[50,12794,12795],{"class":52,"line":6600},[50,12796,170],{"class":207},[11,12798,12799,12802,12803,12806,12807,1390,12810,12813,12814,2185,12817,181],{},[15,12800,12801],{},"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 ",[15,12804,12805],{},"'json'",", for arbitrary bytes ",[15,12808,12809],{},"'arraybuffer'",[15,12811,12812],{},"'blob'",". Set it ",[504,12815,12816],{},"before",[15,12818,12819],{},"send()",[11,12821,12822,12823,12825,12826,12828,12829,12831,12832,12834,12835,12837],{},"The server has to send a ",[15,12824,11866],{}," header if you want a meaningful ",[15,12827,11873],{},". Chunked transfer encoding doesn't include one, and ",[15,12830,11787],{}," will be ",[15,12833,4639],{},". CDNs that gzip on the fly also sometimes strip ",[15,12836,11866],{},". When that happens, you're stuck with a bytes counter and no percentage — which is honest.",[30,12839,12841,12842],{"id":12840},"wrapping-xhr-to-look-like-fetch","Wrapping XHR to look like ",[15,12843,6704],{},[11,12845,12846,12847,12849,12850,12852],{},"Most of a modern codebase is ",[15,12848,6704],{},"-shaped. If you do real progress in XHR but everything else in ",[15,12851,6704],{},", you end up with two parallel request pipelines — different error types, different abort semantics, different header normalization.",[11,12854,12855,12856,12858,12859,12864],{},"The trick is to let ",[15,12857,6704],{}," be the default and swap in an XHR-backed function ",[504,12860,12861,12862],{},"that returns a ",[15,12863,9389],{}," when progress is needed. The calling code doesn't change.",[11,12866,12867],{},"Here's a wrapper that does exactly that (this is the real implementation from my api-provider package):",[41,12869,12871],{"className":187,"code":12870,"language":189,"meta":46,"style":46},"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",[15,12872,12873,12884,12905,12939,12958,12982,13011,13042,13067,13071,13097,13114,13131,13138,13142,13146,13160,13174,13185,13206,13210,13217,13246,13260,13272,13276,13280,13284,13317,13346,13350,13369,13376,13383,13404,13410,13415,13433,13438,13443,13458,13476,13500,13506,13512,13530,13536,13542,13570,13601,13634,13639,13649,13655,13660],{"__ignoreMap":46},[50,12874,12875,12877,12879,12882],{"class":52,"line":53},[50,12876,197],{"class":196},[50,12878,4231],{"class":196},[50,12880,12881],{"class":203}," createXhrFetch",[50,12883,979],{"class":207},[50,12885,12886,12888,12890,12892,12894,12896,12899,12901,12903],{"class":52,"line":59},[50,12887,11536],{"class":203},[50,12889,230],{"class":196},[50,12891,420],{"class":207},[50,12893,8338],{"class":226},[50,12895,230],{"class":196},[50,12897,12898],{"class":203}," RequestProgress",[50,12900,440],{"class":207},[50,12902,362],{"class":196},[50,12904,12071],{"class":217},[50,12906,12907,12909,12911,12913,12915,12917,12919,12921,12923,12925,12927,12929,12931,12933,12935,12937],{"class":52,"line":65},[50,12908,1643],{"class":207},[50,12910,230],{"class":196},[50,12912,420],{"class":207},[50,12914,7162],{"class":226},[50,12916,230],{"class":196},[50,12918,233],{"class":217},[50,12920,284],{"class":207},[50,12922,9656],{"class":226},[50,12924,230],{"class":196},[50,12926,9661],{"class":203},[50,12928,440],{"class":207},[50,12930,362],{"class":196},[50,12932,4242],{"class":203},[50,12934,208],{"class":207},[50,12936,9389],{"class":203},[50,12938,221],{"class":207},[50,12940,12941,12943,12945,12948,12950,12952,12954,12956],{"class":52,"line":71},[50,12942,973],{"class":196},[50,12944,4231],{"class":196},[50,12946,12947],{"class":203}," xhrFetch",[50,12949,1073],{"class":207},[50,12951,7162],{"class":226},[50,12953,284],{"class":207},[50,12955,9656],{"class":226},[50,12957,1718],{"class":207},[50,12959,12960,12962,12965,12967,12970,12972,12975,12977,12980],{"class":52,"line":77},[50,12961,3224],{"class":196},[50,12963,12964],{"class":217}," method",[50,12966,214],{"class":196},[50,12968,12969],{"class":207}," (init.method ",[50,12971,7034],{"class":196},[50,12973,12974],{"class":311}," 'GET'",[50,12976,1670],{"class":207},[50,12978,12979],{"class":203},"toUpperCase",[50,12981,1999],{"class":207},[50,12983,12984,12986,12989,12991,12994,12996,12998,13000,13002,13005,13007,13009],{"class":52,"line":83},[50,12985,3224],{"class":196},[50,12987,12988],{"class":217}," body",[50,12990,214],{"class":196},[50,12992,12993],{"class":207}," (init.body ",[50,12995,7034],{"class":196},[50,12997,4667],{"class":217},[50,12999,440],{"class":207},[50,13001,1537],{"class":196},[50,13003,13004],{"class":203}," XMLHttpRequestBodyInit",[50,13006,236],{"class":196},[50,13008,4667],{"class":217},[50,13010,325],{"class":207},[50,13012,13013,13015,13017,13019,13022,13024,13026,13028,13030,13032,13034,13036,13038,13040],{"class":52,"line":89},[50,13014,3224],{"class":196},[50,13016,9908],{"class":217},[50,13018,214],{"class":196},[50,13020,13021],{"class":207}," init.headers ",[50,13023,1537],{"class":196},[50,13025,276],{"class":203},[50,13027,208],{"class":207},[50,13029,281],{"class":217},[50,13031,284],{"class":207},[50,13033,281],{"class":217},[50,13035,290],{"class":207},[50,13037,293],{"class":196},[50,13039,1752],{"class":217},[50,13041,325],{"class":207},[50,13043,13044,13046,13048,13050,13053,13055,13057,13059,13061,13063,13065],{"class":52,"line":95},[50,13045,3224],{"class":196},[50,13047,10049],{"class":217},[50,13049,214],{"class":196},[50,13051,13052],{"class":207}," init.signal ",[50,13054,1537],{"class":196},[50,13056,10103],{"class":203},[50,13058,236],{"class":196},[50,13060,4667],{"class":217},[50,13062,236],{"class":196},[50,13064,1752],{"class":217},[50,13066,325],{"class":207},[50,13068,13069],{"class":52,"line":101},[50,13070,117],{"emptyLinePlaceholder":116},[50,13072,13073,13075,13077,13079,13081,13083,13085,13087,13089,13091,13093,13095],{"class":52,"line":107},[50,13074,1558],{"class":196},[50,13076,2279],{"class":196},[50,13078,4242],{"class":217},[50,13080,208],{"class":207},[50,13082,9389],{"class":203},[50,13084,12100],{"class":207},[50,13086,12103],{"class":226},[50,13088,284],{"class":207},[50,13090,12108],{"class":226},[50,13092,440],{"class":207},[50,13094,362],{"class":196},[50,13096,968],{"class":207},[50,13098,13099,13101,13103,13105,13108,13110,13112],{"class":52,"line":113},[50,13100,6352],{"class":196},[50,13102,420],{"class":207},[50,13104,1472],{"class":196},[50,13106,13107],{"class":207}," XMLHttpRequest ",[50,13109,1297],{"class":196},[50,13111,1952],{"class":311},[50,13113,1718],{"class":207},[50,13115,13116,13118,13120,13122,13124,13126,13129],{"class":52,"line":120},[50,13117,12722],{"class":203},[50,13119,1073],{"class":207},[50,13121,5184],{"class":196},[50,13123,6503],{"class":203},[50,13125,1073],{"class":207},[50,13127,13128],{"class":311},"'XHR unavailable in this runtime'",[50,13130,12285],{"class":207},[50,13132,13133,13136],{"class":52,"line":126},[50,13134,13135],{"class":196},"        return",[50,13137,325],{"class":207},[50,13139,13140],{"class":52,"line":132},[50,13141,6432],{"class":207},[50,13143,13144],{"class":52,"line":138},[50,13145,117],{"emptyLinePlaceholder":116},[50,13147,13148,13150,13152,13154,13156,13158],{"class":52,"line":144},[50,13149,6329],{"class":196},[50,13151,12121],{"class":217},[50,13153,214],{"class":196},[50,13155,2279],{"class":196},[50,13157,12128],{"class":203},[50,13159,1999],{"class":207},[50,13161,13162,13165,13167,13170,13172],{"class":52,"line":150},[50,13163,13164],{"class":207},"      xhr.",[50,13166,12138],{"class":203},[50,13168,13169],{"class":207},"(method, input, ",[50,13171,3493],{"class":217},[50,13173,1971],{"class":207},[50,13175,13176,13179,13181,13183],{"class":52,"line":156},[50,13177,13178],{"class":207},"      xhr.responseType ",[50,13180,639],{"class":196},[50,13182,12601],{"class":311},[50,13184,325],{"class":207},[50,13186,13187,13189,13192,13194,13197,13200,13202,13204],{"class":52,"line":162},[50,13188,6352],{"class":196},[50,13190,13191],{"class":207}," (init.credentials ",[50,13193,1297],{"class":196},[50,13195,13196],{"class":311}," 'include'",[50,13198,13199],{"class":207},") xhr.withCredentials ",[50,13201,639],{"class":196},[50,13203,2936],{"class":217},[50,13205,325],{"class":207},[50,13207,13208],{"class":52,"line":167},[50,13209,117],{"emptyLinePlaceholder":116},[50,13211,13212,13214],{"class":52,"line":3263},[50,13213,6352],{"class":196},[50,13215,13216],{"class":207}," (headers) {\n",[50,13218,13219,13222,13224,13226,13228,13230,13232,13234,13236,13239,13241,13243],{"class":52,"line":3275},[50,13220,13221],{"class":196},"        for",[50,13223,420],{"class":207},[50,13225,952],{"class":196},[50,13227,1106],{"class":207},[50,13229,7293],{"class":217},[50,13231,284],{"class":207},[50,13233,5666],{"class":217},[50,13235,7834],{"class":207},[50,13237,13238],{"class":196},"of",[50,13240,1987],{"class":207},[50,13242,5044],{"class":203},[50,13244,13245],{"class":207},"(headers)) {\n",[50,13247,13248,13251,13254,13257],{"class":52,"line":6553},[50,13249,13250],{"class":196},"          try",[50,13252,13253],{"class":207}," { xhr.",[50,13255,13256],{"class":203},"setRequestHeader",[50,13258,13259],{"class":207},"(name, value); }\n",[50,13261,13262,13265,13267,13270],{"class":52,"line":6569},[50,13263,13264],{"class":196},"          catch",[50,13266,2856],{"class":207},[50,13268,13269],{"class":1853},"\u002F* forbidden headers are silently skipped *\u002F",[50,13271,3502],{"class":207},[50,13273,13274],{"class":52,"line":6592},[50,13275,6427],{"class":207},[50,13277,13278],{"class":52,"line":6600},[50,13279,6432],{"class":207},[50,13281,13282],{"class":52,"line":6629},[50,13283,117],{"emptyLinePlaceholder":116},[50,13285,13286,13289,13291,13293,13295,13297,13299,13301,13304,13306,13309,13311,13314],{"class":52,"line":6642},[50,13287,13288],{"class":207},"      xhr.upload.",[50,13290,11592],{"class":203},[50,13292,214],{"class":196},[50,13294,420],{"class":207},[50,13296,11599],{"class":226},[50,13298,440],{"class":207},[50,13300,362],{"class":196},[50,13302,13303],{"class":203}," onProgress",[50,13305,1073],{"class":207},[50,13307,13308],{"class":203},"normaliseProgress",[50,13310,1073],{"class":207},[50,13312,13313],{"class":311},"'upload'",[50,13315,13316],{"class":207},", e));\n",[50,13318,13319,13321,13323,13325,13327,13329,13331,13333,13335,13337,13339,13341,13344],{"class":52,"line":6647},[50,13320,13164],{"class":207},[50,13322,11592],{"class":203},[50,13324,11625],{"class":196},[50,13326,420],{"class":207},[50,13328,11599],{"class":226},[50,13330,440],{"class":207},[50,13332,362],{"class":196},[50,13334,13303],{"class":203},[50,13336,1073],{"class":207},[50,13338,13308],{"class":203},[50,13340,1073],{"class":207},[50,13342,13343],{"class":311},"'download'",[50,13345,13316],{"class":207},[50,13347,13348],{"class":52,"line":6654},[50,13349,117],{"emptyLinePlaceholder":116},[50,13351,13352,13354,13356,13358,13360,13362,13365,13367],{"class":52,"line":6663},[50,13353,6329],{"class":196},[50,13355,10207],{"class":203},[50,13357,214],{"class":196},[50,13359,359],{"class":207},[50,13361,362],{"class":196},[50,13363,13364],{"class":207}," xhr.",[50,13366,10219],{"class":203},[50,13368,1999],{"class":207},[50,13370,13371,13373],{"class":52,"line":6669},[50,13372,6352],{"class":196},[50,13374,13375],{"class":207}," (signal) {\n",[50,13377,13378,13380],{"class":52,"line":6681},[50,13379,6389],{"class":196},[50,13381,13382],{"class":207}," (signal.aborted) {\n",[50,13384,13385,13388,13390,13392,13394,13396,13398,13400,13402],{"class":52,"line":6688},[50,13386,13387],{"class":203},"          reject",[50,13389,1073],{"class":207},[50,13391,5184],{"class":196},[50,13393,12338],{"class":203},[50,13395,1073],{"class":207},[50,13397,12343],{"class":311},[50,13399,284],{"class":207},[50,13401,12348],{"class":311},[50,13403,12285],{"class":207},[50,13405,13406,13408],{"class":52,"line":6693},[50,13407,6420],{"class":196},[50,13409,325],{"class":207},[50,13411,13413],{"class":52,"line":13412},36,[50,13414,6427],{"class":207},[50,13416,13418,13421,13423,13425,13427,13429,13431],{"class":52,"line":13417},37,[50,13419,13420],{"class":207},"        signal.",[50,13422,5462],{"class":203},[50,13424,1073],{"class":207},[50,13426,10233],{"class":311},[50,13428,10236],{"class":207},[50,13430,3493],{"class":217},[50,13432,6134],{"class":207},[50,13434,13436],{"class":52,"line":13435},38,[50,13437,6432],{"class":207},[50,13439,13441],{"class":52,"line":13440},39,[50,13442,117],{"emptyLinePlaceholder":116},[50,13444,13446,13448,13450,13452,13454,13456],{"class":52,"line":13445},40,[50,13447,13164],{"class":207},[50,13449,11651],{"class":203},[50,13451,214],{"class":196},[50,13453,359],{"class":207},[50,13455,362],{"class":196},[50,13457,968],{"class":207},[50,13459,13461,13463,13466,13469,13471,13473],{"class":52,"line":13460},41,[50,13462,6389],{"class":196},[50,13464,13465],{"class":207}," (signal) signal.",[50,13467,13468],{"class":203},"removeEventListener",[50,13470,1073],{"class":207},[50,13472,10233],{"class":311},[50,13474,13475],{"class":207},", onAbort);\n",[50,13477,13479,13481,13483,13485,13487,13489,13491,13494,13496,13498],{"class":52,"line":13478},42,[50,13480,12698],{"class":203},[50,13482,1073],{"class":207},[50,13484,5184],{"class":196},[50,13486,10369],{"class":203},[50,13488,12701],{"class":207},[50,13490,1537],{"class":196},[50,13492,13493],{"class":203}," BodyInit",[50,13495,236],{"class":196},[50,13497,4667],{"class":217},[50,13499,1454],{"class":207},[50,13501,13503],{"class":52,"line":13502},43,[50,13504,13505],{"class":207},"          status: xhr.status,\n",[50,13507,13509],{"class":52,"line":13508},44,[50,13510,13511],{"class":207},"          statusText: xhr.statusText,\n",[50,13513,13515,13518,13521,13524,13527],{"class":52,"line":13514},45,[50,13516,13517],{"class":207},"          headers: ",[50,13519,13520],{"class":203},"parseHeaders",[50,13522,13523],{"class":207},"(xhr.",[50,13525,13526],{"class":203},"getAllResponseHeaders",[50,13528,13529],{"class":207},"()),\n",[50,13531,13533],{"class":52,"line":13532},46,[50,13534,13535],{"class":207},"        }));\n",[50,13537,13539],{"class":52,"line":13538},47,[50,13540,13541],{"class":207},"      };\n",[50,13543,13545,13547,13549,13552,13554,13556,13558,13560,13562,13564,13566,13568],{"class":52,"line":13544},48,[50,13546,13164],{"class":207},[50,13548,11672],{"class":203},[50,13550,13551],{"class":196},"   =",[50,13553,359],{"class":207},[50,13555,362],{"class":196},[50,13557,12263],{"class":203},[50,13559,1073],{"class":207},[50,13561,5184],{"class":196},[50,13563,12310],{"class":203},[50,13565,1073],{"class":207},[50,13567,12315],{"class":311},[50,13569,12285],{"class":207},[50,13571,13573,13575,13577,13579,13581,13583,13585,13587,13589,13591,13593,13595,13597,13599],{"class":52,"line":13572},49,[50,13574,13164],{"class":207},[50,13576,11693],{"class":203},[50,13578,13551],{"class":196},[50,13580,359],{"class":207},[50,13582,362],{"class":196},[50,13584,12263],{"class":203},[50,13586,1073],{"class":207},[50,13588,5184],{"class":196},[50,13590,12338],{"class":203},[50,13592,1073],{"class":207},[50,13594,12343],{"class":311},[50,13596,284],{"class":207},[50,13598,12348],{"class":311},[50,13600,12285],{"class":207},[50,13602,13604,13606,13608,13610,13612,13614,13616,13618,13620,13622,13624,13627,13629,13632],{"class":52,"line":13603},50,[50,13605,13164],{"class":207},[50,13607,11713],{"class":203},[50,13609,214],{"class":196},[50,13611,359],{"class":207},[50,13613,362],{"class":196},[50,13615,12263],{"class":203},[50,13617,1073],{"class":207},[50,13619,5184],{"class":196},[50,13621,12338],{"class":203},[50,13623,1073],{"class":207},[50,13625,13626],{"class":311},"'Request timeout'",[50,13628,284],{"class":207},[50,13630,13631],{"class":311},"'TimeoutError'",[50,13633,12285],{"class":207},[50,13635,13637],{"class":52,"line":13636},51,[50,13638,117],{"emptyLinePlaceholder":116},[50,13640,13642,13644,13646],{"class":52,"line":13641},52,[50,13643,13164],{"class":207},[50,13645,12393],{"class":203},[50,13647,13648],{"class":207},"(body);\n",[50,13650,13652],{"class":52,"line":13651},53,[50,13653,13654],{"class":207},"    });\n",[50,13656,13658],{"class":52,"line":13657},54,[50,13659,8710],{"class":207},[50,13661,13663],{"class":52,"line":13662},55,[50,13664,170],{"class":207},[11,13666,13667],{},"Four things are doing real work in this shape:",[11,13669,892,13670,13675,13676,13679],{},[2169,13671,13672,13673],{},"return type is ",[15,13674,9471],{},". That's the whole point — anything that accepts ",[15,13677,13678],{},"typeof fetch"," accepts this function. The rest of the codebase (interceptors, retry, JSON parsing, error mapping) stays transport-agnostic.",[11,13681,892,13682,13685,13686,13689,13690,13693,13694,13696,13697,230],{},[2169,13683,13684],{},"header normalizer at the bottom"," translates ",[15,13687,13688],{},"xhr.getAllResponseHeaders()"," (a raw CRLF-separated string) into a ",[15,13691,13692],{},"Headers"," object so the ",[15,13695,9389],{}," behaves exactly like one from ",[15,13698,6704],{},[41,13700,13702],{"className":187,"code":13701,"language":189,"meta":46,"style":46},"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",[15,13703,13704,13728,13743,13759,13795,13816,13834,13858,13883,13895,13899,13905],{"__ignoreMap":46},[50,13705,13706,13708,13711,13713,13715,13717,13719,13721,13723,13726],{"class":52,"line":53},[50,13707,1895],{"class":196},[50,13709,13710],{"class":203}," parseHeaders",[50,13712,1073],{"class":207},[50,13714,8443],{"class":226},[50,13716,230],{"class":196},[50,13718,233],{"class":217},[50,13720,1643],{"class":207},[50,13722,230],{"class":196},[50,13724,13725],{"class":203}," Headers",[50,13727,968],{"class":207},[50,13729,13730,13732,13735,13737,13739,13741],{"class":52,"line":59},[50,13731,1439],{"class":196},[50,13733,13734],{"class":217}," out",[50,13736,214],{"class":196},[50,13738,2279],{"class":196},[50,13740,13725],{"class":203},[50,13742,1999],{"class":207},[50,13744,13745,13747,13749,13751,13754,13756],{"class":52,"line":65},[50,13746,1270],{"class":196},[50,13748,420],{"class":207},[50,13750,1275],{"class":196},[50,13752,13753],{"class":207},"raw) ",[50,13755,1281],{"class":196},[50,13757,13758],{"class":207}," out;\n",[50,13760,13761,13763,13765,13767,13770,13772,13775,13777,13779,13781,13783,13785,13788,13790,13792],{"class":52,"line":71},[50,13762,6971],{"class":196},[50,13764,420],{"class":207},[50,13766,952],{"class":196},[50,13768,13769],{"class":217}," line",[50,13771,6981],{"class":196},[50,13773,13774],{"class":207}," raw.",[50,13776,7230],{"class":203},[50,13778,7124],{"class":207},[50,13780,1662],{"class":203},[50,13782,1073],{"class":207},[50,13784,7205],{"class":311},[50,13786,13787],{"class":217},"[\\r\\n]",[50,13789,7211],{"class":196},[50,13791,7205],{"class":311},[50,13793,13794],{"class":207},")) {\n",[50,13796,13797,13799,13802,13804,13807,13809,13811,13814],{"class":52,"line":77},[50,13798,3224],{"class":196},[50,13800,13801],{"class":217}," idx",[50,13803,214],{"class":196},[50,13805,13806],{"class":207}," line.",[50,13808,10756],{"class":203},[50,13810,1073],{"class":207},[50,13812,13813],{"class":311},"':'",[50,13815,1971],{"class":207},[50,13817,13818,13820,13823,13825,13827,13829,13832],{"class":52,"line":83},[50,13819,1699],{"class":196},[50,13821,13822],{"class":207}," (idx ",[50,13824,208],{"class":196},[50,13826,2433],{"class":217},[50,13828,440],{"class":207},[50,13830,13831],{"class":196},"continue",[50,13833,325],{"class":207},[50,13835,13836,13838,13841,13843,13845,13847,13849,13851,13854,13856],{"class":52,"line":89},[50,13837,3224],{"class":196},[50,13839,13840],{"class":217}," name",[50,13842,214],{"class":196},[50,13844,13806],{"class":207},[50,13846,7389],{"class":203},[50,13848,1073],{"class":207},[50,13850,5065],{"class":217},[50,13852,13853],{"class":207},", idx).",[50,13855,7230],{"class":203},[50,13857,1999],{"class":207},[50,13859,13860,13862,13864,13866,13868,13870,13873,13875,13877,13879,13881],{"class":52,"line":95},[50,13861,3224],{"class":196},[50,13863,5677],{"class":217},[50,13865,214],{"class":196},[50,13867,13806],{"class":207},[50,13869,7389],{"class":203},[50,13871,13872],{"class":207},"(idx ",[50,13874,7211],{"class":196},[50,13876,4269],{"class":217},[50,13878,1670],{"class":207},[50,13880,7230],{"class":203},[50,13882,1999],{"class":207},[50,13884,13885,13887,13890,13892],{"class":52,"line":101},[50,13886,1699],{"class":196},[50,13888,13889],{"class":207}," (name) out.",[50,13891,12378],{"class":203},[50,13893,13894],{"class":207},"(name, value);\n",[50,13896,13897],{"class":52,"line":107},[50,13898,110],{"class":207},[50,13900,13901,13903],{"class":52,"line":113},[50,13902,973],{"class":196},[50,13904,13758],{"class":207},[50,13906,13907],{"class":52,"line":120},[50,13908,170],{"class":207},[11,13910,892,13911,13914,13915,13917,13918,13921,13922,13925,13926,13929],{},[2169,13912,13913],{},"AbortSignal bridge"," converts between ",[15,13916,6704],{},"'s cancellation primitive and XHR's ",[15,13919,13920],{},"abort()"," method. ",[15,13923,13924],{},"AbortSignal.addEventListener('abort', xhr.abort)"," makes the two APIs speak the same language; checking ",[15,13927,13928],{},"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,13931,892,13932,13939,13940,284,13943,284,13946,13949,13950,5889,13953,13956,13957,13959,13960,13962],{},[2169,13933,13934,13936,13937],{},[15,13935,10283],{}," around ",[15,13938,13256],{}," is deliberate. Browsers forbid certain headers (",[15,13941,13942],{},"User-Agent",[15,13944,13945],{},"Cookie",[15,13947,13948],{},"Host",", most ",[15,13951,13952],{},"Sec-*",[15,13954,13955],{},"Proxy-*"," variants). ",[15,13958,6704],{}," silently ignores them too; the ",[15,13961,10283],{}," makes XHR match the behavior.",[30,13964,13966],{"id":13965},"aborting-timing-out-and-erroring","Aborting, timing out, and erroring",[11,13968,13969,13970,284,13972,284,13974,284,13976,13978,13979,13981],{},"The four terminal outcomes of an XHR are ",[15,13971,11651],{},[15,13973,11672],{},[15,13975,11693],{},[15,13977,11713],{},". They're mutually exclusive — exactly one fires per request. The translation to ",[15,13980,6704],{},"-world:",[13983,13984,13985,13999],"table",{},[611,13986,13987],{},[621,13988,13989,13993,13996],{},[13990,13991,13992],"th",{},"XHR event",[13990,13994,13995],{},"Meaning",[13990,13997,13998],{},"Promise shape",[793,14000,14001,14016,14030,14047],{},[621,14002,14003,14008,14011],{},[14004,14005,14006],"td",{},[15,14007,11651],{},[14004,14009,14010],{},"Server replied (any status)",[14004,14012,14013],{},[15,14014,14015],{},"resolve(new Response(...))",[621,14017,14018,14022,14025],{},[14004,14019,14020],{},[15,14021,11672],{},[14004,14023,14024],{},"Network failure, CORS block, DNS",[14004,14026,14027],{},[15,14028,14029],{},"reject(new TypeError('Network error'))",[621,14031,14032,14036,14042],{},[14004,14033,14034],{},[15,14035,11693],{},[14004,14037,14038,14041],{},[15,14039,14040],{},"xhr.abort()"," was called",[14004,14043,14044],{},[15,14045,14046],{},"reject(new DOMException('...', 'AbortError'))",[621,14048,14049,14053,14059],{},[14004,14050,14051],{},[15,14052,11713],{},[14004,14054,14055,14058],{},[15,14056,14057],{},"xhr.timeout"," ms elapsed",[14004,14060,14061],{},[15,14062,14063],{},"reject(new DOMException('...', 'TimeoutError'))",[11,14065,14066,7590,14068,14071,14072,14075],{},[15,14067,6704],{},[15,14069,14070],{},"TypeError"," for network errors and ",[15,14073,14074],{},"DOMException"," for abort. Matching those types means existing error handlers keep working when you swap transports.",[11,14077,14078,14079,14082,14083,14085,14086,14088,14089,14088,14091,14094],{},"A caller-set ",[15,14080,14081],{},"xhr.timeout = 30_000"," fires ",[15,14084,11713],{}," automatically without any extra code. That's one of the small wins of XHR over ",[15,14087,6704],{}," + ",[15,14090,5950],{},[15,14092,14093],{},"setTimeout"," — the timeout machinery is built in.",[30,14096,14098],{"id":14097},"pitfalls-worth-naming","Pitfalls worth naming",[11,14100,14101],{},"A handful of traps that cost me time the first time I hit them.",[11,14103,14104,14110,14111,14114],{},[2169,14105,14106,14107,14109],{},"Setting ",[15,14108,12417],{}," with FormData breaks multipart."," The browser's automatic ",[15,14112,14113],{},"multipart\u002Fform-data; boundary=..."," header is the only one the server can parse. Don't override it.",[11,14116,14117,14120,14121,14124,14125,14128],{},[2169,14118,14119],{},"CORS progress is restricted."," For cross-origin requests, upload progress events only fire if the response includes the appropriate ",[15,14122,14123],{},"Access-Control-Allow-Origin"," header. If you're seeing ",[15,14126,14127],{},"loaded = 0"," forever on a cross-origin upload, it's almost certainly CORS, not your code.",[11,14130,14131,14139],{},[2169,14132,14133,14136,14137,181],{},[15,14134,14135],{},"responseType"," must be set before ",[15,14138,12819],{}," Setting it after is a no-op. The browser needs to know how to buffer the response from the first byte.",[11,14141,14142,14147,14148,14150,14151,14154],{},[2169,14143,14144,14145,181],{},"Gzip and ",[15,14146,11866],{}," Many CDNs strip ",[15,14149,11866],{}," when they apply gzip on the fly (because the compressed length is different from the decompressed length). Your client sees ",[15,14152,14153],{},"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,14156,14157,2185,14160,14163,14164,14167,14168,14171],{},[2169,14158,14159],{},"Don't hold the whole response in memory unnecessarily.",[15,14161,14162],{},"responseType: 'blob'"," lets the browser manage the buffer; ",[15,14165,14166],{},"responseType: 'arraybuffer'"," forces it into JS-heap memory. For anything above a few MB, ",[15,14169,14170],{},"blob"," is the right default.",[30,14173,14175],{"id":14174},"what-id-reach-for-first","What I'd reach for first",[11,14177,14178],{},"For any project that needs real progress UIs, the shape I'd build every time:",[2163,14180,14181,14189,14199,14207,14220],{},[2166,14182,14183,14188],{},[2169,14184,14185,14186,181],{},"Default transport is ",[15,14187,6704],{}," It's the modern primitive; it's faster; it hydrates into SSR-friendly patterns cleanly.",[2166,14190,14191,14194,14195,14198],{},[2169,14192,14193],{},"Opt into XHR only when the caller asks for progress."," A single ",[15,14196,14197],{},"onRequestProgress"," option is enough to swap transports for that specific call.",[2166,14200,14201,14206],{},[2169,14202,14203,14204],{},"The XHR path returns a ",[15,14205,9471],{},", so everything downstream keeps working — interceptors, retry, error mapping, JSON parsing.",[2166,14208,14209,14216,14217,14219],{},[2169,14210,14211,14212,14215],{},"Normalize ",[15,14213,14214],{},"{ loaded, total, ratio }"," at the transport layer."," Hand ",[15,14218,4673],{}," upstream when the total is unknown. Let the UI decide how to render that state.",[2166,14221,14222,14228,14229,14231,14232,14234],{},[2169,14223,14224,14225,14227],{},"Bridge ",[15,14226,10032],{}," once"," in the wrapper, not in every caller. XHR's ",[15,14230,13920],{}," and fetch's ",[15,14233,5950],{}," exist in the same logical slot; the wrapper translates.",[11,14236,14237],{},"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.",[4011,14239,14240],{},"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":46,"searchDepth":59,"depth":59,"links":14242},[14243,14245,14253,14255,14256,14258,14259,14260,14262,14263,14264],{"id":9453,"depth":59,"text":14244},"Why native fetch is the default primitive",{"id":9596,"depth":59,"text":9597,"children":14246},[14247,14248,14250,14251,14252],{"id":9618,"depth":65,"text":9619},{"id":10028,"depth":65,"text":14249},"Layer 2: timeouts via AbortSignal",{"id":10291,"depth":65,"text":10292},{"id":11000,"depth":65,"text":11001},{"id":11222,"depth":65,"text":11223},{"id":11372,"depth":59,"text":14254},"Why fetch can't show upload progress",{"id":11575,"depth":59,"text":11576},{"id":11780,"depth":59,"text":14257},"The ProgressEvent shape, and lengthComputable",{"id":12005,"depth":59,"text":12006},{"id":12452,"depth":59,"text":12453},{"id":12840,"depth":59,"text":14261},"Wrapping XHR to look like fetch",{"id":13965,"depth":59,"text":13966},{"id":14097,"depth":59,"text":14098},{"id":14174,"depth":59,"text":14175},"Why native fetch is the right default, how to build a layered wrapper (base URL, timeouts, interceptors, retry), and when you still need XHR.",[14267,9415,14268,14269,14270,5950,10032,14271,14272,14273,14274,5953],"fetch API","XHR","upload progress","download progress","custom HTTP client","interceptors","retry","exponential backoff",{},"\u002Fblog\u002Fupload-and-download-progress-in-the-browser",{"title":9372,"description":14265},"blog\u002Fupload-and-download-progress-in-the-browser","yNEqK6I9iRd1GgZmicN4yjuc76LPsqM55hf-R9l84RE",{"id":14281,"title":14282,"body":14283,"date":15951,"description":15952,"draft":4039,"extension":4040,"image":4041,"keywords":15953,"lang":4041,"meta":15963,"navigation":116,"path":15964,"seo":15965,"stem":15966,"updatedAt":4041,"__hash__":15967},"blog\u002Fblog\u002Fbuilding-ali-nuxt-toolkit.md","Building ali-nuxt-toolkit — a tour of the internals",{"type":8,"value":14284,"toc":15928},[14285,14291,14294,14298,14303,14337,14340,14366,14369,14373,14376,14641,14656,14664,14672,14683,14777,14784,14791,14794,14827,14830,14927,14932,14936,14954,14988,14991,14995,14998,15061,15068,15086,15093,15098,15243,15260,15267,15270,15380,15387,15451,15458,15465,15468,15475,15630,15647,15653,15753,15760,15764,15767,15774,15777,15781,15791,15794,15830,15834,15845,15853,15868,15872,15875,15915,15925],[11,14286,14287,14288,181],{},"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: ",[2169,14289,14290],{},"ali-nuxt-toolkit",[11,14292,14293],{},"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.",[30,14295,14297],{"id":14296},"the-shape-of-the-repo","The shape of the repo",[11,14299,14300,14302],{},[15,14301,14290],{}," is a pnpm monorepo. The top level is roughly:",[2163,14304,14305,14315,14325,14331],{},[2166,14306,14307,14310,14311,14314],{},[15,14308,14309],{},"packages\u002F"," — three independently published modules under the ",[15,14312,14313],{},"@alikhalilll"," scope.",[2166,14316,14317,14320,14321,14324],{},[15,14318,14319],{},"apps\u002Fdocs\u002F"," — a Nuxt 4 + ",[15,14322,14323],{},"@nuxt\u002Fcontent"," site, prerendered to static HTML.",[2166,14326,14327,14330],{},[15,14328,14329],{},"playgrounds\u002Fnuxt\u002F"," — a minimal app that wires all three modules together; handy for kicking the tires locally.",[2166,14332,14333,14336],{},[15,14334,14335],{},".github\u002Fworkflows\u002F"," — CI (lint, typecheck, matrix build on Node 20 + 22) and a Changesets-driven release pipeline.",[11,14338,14339],{},"The three packages:",[2163,14341,14342,14350,14358],{},[2166,14343,14344,14349],{},[2169,14345,14346],{},[15,14347,14348],{},"@alikhalilll\u002Fnuxt-api-provider"," — strongly-typed fetch client with an interceptor chain, retry\u002Fbackoff, timeouts, and upload\u002Fdownload progress.",[2166,14351,14352,14357],{},[2169,14353,14354],{},[15,14355,14356],{},"@alikhalilll\u002Fnuxt-auto-middleware"," — layout-scoped route middleware with glob patterns, named groups, and per-page overrides.",[2166,14359,14360,14365],{},[2169,14361,14362],{},[15,14363,14364],{},"@alikhalilll\u002Fnuxt-crypto"," — AES-256-GCM + PBKDF2 built on Web Crypto, with an LRU key cache and pluggable algorithms.",[11,14367,14368],{},"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.",[30,14370,14372],{"id":14371},"the-module-skeleton","The module skeleton",[11,14374,14375],{},"All three modules follow the same Nuxt 4 shape:",[41,14377,14379],{"className":187,"code":14378,"language":189,"meta":46,"style":46},"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",[15,14380,14381,14398,14403,14408,14413,14423,14427,14437,14453,14458,14465,14475,14511,14515,14519,14524,14547,14551,14556,14570,14579,14590,14594,14619,14629,14633,14637],{"__ignoreMap":46},[50,14382,14383,14385,14388,14391,14393,14396],{"class":52,"line":53},[50,14384,197],{"class":196},[50,14386,14387],{"class":196}," default",[50,14389,14390],{"class":203}," defineNuxtModule",[50,14392,208],{"class":207},[50,14394,14395],{"class":203},"Options",[50,14397,8181],{"class":207},[50,14399,14400],{"class":52,"line":59},[50,14401,14402],{"class":207},"  meta: {\n",[50,14404,14405],{"class":52,"line":65},[50,14406,14407],{"class":207},"    name,\n",[50,14409,14410],{"class":52,"line":71},[50,14411,14412],{"class":207},"    configKey,\n",[50,14414,14415,14418,14421],{"class":52,"line":77},[50,14416,14417],{"class":207},"    compatibility: { nuxt: ",[50,14419,14420],{"class":311},"'>=3.0.0'",[50,14422,1085],{"class":207},[50,14424,14425],{"class":52,"line":83},[50,14426,3646],{"class":207},[50,14428,14429,14432,14435],{"class":52,"line":89},[50,14430,14431],{"class":207},"  defaults: { ",[50,14433,14434],{"class":1853},"\u002F* ... *\u002F",[50,14436,1085],{"class":207},[50,14438,14439,14442,14444,14446,14448,14451],{"class":52,"line":95},[50,14440,14441],{"class":203},"  setup",[50,14443,1073],{"class":207},[50,14445,6254],{"class":226},[50,14447,284],{"class":207},[50,14449,14450],{"class":226},"nuxt",[50,14452,1718],{"class":207},[50,14454,14455],{"class":52,"line":101},[50,14456,14457],{"class":1853},"    \u002F\u002F 1. Write a serialized config file into .nuxt\n",[50,14459,14460,14463],{"class":52,"line":107},[50,14461,14462],{"class":203},"    addTemplate",[50,14464,11245],{"class":207},[50,14466,14467,14470,14473],{"class":52,"line":113},[50,14468,14469],{"class":207},"      filename: ",[50,14471,14472],{"class":311},"'my-module-config.mjs'",[50,14474,999],{"class":207},[50,14476,14477,14480,14482,14484,14487,14489,14491,14493,14495,14498,14500,14503,14506,14509],{"class":52,"line":120},[50,14478,14479],{"class":203},"      getContents",[50,14481,8189],{"class":207},[50,14483,362],{"class":196},[50,14485,14486],{"class":311}," `export default ${",[50,14488,6618],{"class":217},[50,14490,181],{"class":311},[50,14492,6623],{"class":203},[50,14494,1073],{"class":311},[50,14496,14497],{"class":207},"config",[50,14499,1643],{"class":311},[50,14501,14502],{"class":311},"};",[50,14504,14505],{"class":217},"\\n",[50,14507,14508],{"class":311},"`",[50,14510,999],{"class":207},[50,14512,14513],{"class":52,"line":126},[50,14514,13654],{"class":207},[50,14516,14517],{"class":52,"line":132},[50,14518,117],{"emptyLinePlaceholder":116},[50,14520,14521],{"class":52,"line":138},[50,14522,14523],{"class":1853},"    \u002F\u002F 2. Register the runtime plugin\n",[50,14525,14526,14529,14532,14534,14536,14539,14542,14545],{"class":52,"line":144},[50,14527,14528],{"class":203},"    addPlugin",[50,14530,14531],{"class":207},"({ src: resolver.",[50,14533,12103],{"class":203},[50,14535,1073],{"class":207},[50,14537,14538],{"class":311},"'.\u002Fruntime\u002Fplugin'",[50,14540,14541],{"class":207},"), mode: ",[50,14543,14544],{"class":311},"'all'",[50,14546,6134],{"class":207},[50,14548,14549],{"class":52,"line":150},[50,14550,117],{"emptyLinePlaceholder":116},[50,14552,14553],{"class":52,"line":156},[50,14554,14555],{"class":1853},"    \u002F\u002F 3. Augment Nuxt's types so $myModule shows up everywhere\n",[50,14557,14558,14560,14563,14565,14568],{"class":52,"line":162},[50,14559,3224],{"class":196},[50,14561,14562],{"class":217}," typesTemplate",[50,14564,214],{"class":196},[50,14566,14567],{"class":203}," addTemplate",[50,14569,11245],{"class":207},[50,14571,14572,14574,14577],{"class":52,"line":167},[50,14573,14469],{"class":207},[50,14575,14576],{"class":311},"'types\u002Fmy-module.d.ts'",[50,14578,999],{"class":207},[50,14580,14581,14583,14585,14587],{"class":52,"line":3263},[50,14582,14479],{"class":203},[50,14584,8189],{"class":207},[50,14586,362],{"class":196},[50,14588,14589],{"class":207}," typeDeclarations,\n",[50,14591,14592],{"class":52,"line":3275},[50,14593,13654],{"class":207},[50,14595,14596,14599,14602,14604,14607,14610,14613,14615,14617],{"class":52,"line":6553},[50,14597,14598],{"class":207},"    nuxt.",[50,14600,14601],{"class":203},"hook",[50,14603,1073],{"class":207},[50,14605,14606],{"class":311},"'prepare:types'",[50,14608,14609],{"class":207},", ({ ",[50,14611,14612],{"class":226},"references",[50,14614,10336],{"class":207},[50,14616,362],{"class":196},[50,14618,968],{"class":207},[50,14620,14621,14624,14626],{"class":52,"line":6569},[50,14622,14623],{"class":207},"      references.",[50,14625,7044],{"class":203},[50,14627,14628],{"class":207},"({ path: typesTemplate.dst });\n",[50,14630,14631],{"class":52,"line":6592},[50,14632,13654],{"class":207},[50,14634,14635],{"class":52,"line":6600},[50,14636,3646],{"class":207},[50,14638,14639],{"class":52,"line":6629},[50,14640,1153],{"class":207},[11,14642,14643,14644,14647,14648,14651,14652,14655],{},"The interesting part is ",[504,14645,14646],{},"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 ",[15,14649,14650],{},"module.ts",". Everything flows through generated files in ",[15,14653,14654],{},".nuxt",". This has two benefits:",[9424,14657,14658,14661],{},[2166,14659,14660],{},"The runtime plugin stays tiny — it just imports a plain JS object from a virtual path. No work at boot.",[2166,14662,14663],{},"Tree-shaking works. If a feature isn't used, its template contents aren't referenced, and the bundle drops it.",[1349,14665,14667,14668,14671],{"id":14666},"virtual-modules-and-how-to-keep-tsc-happy","Virtual modules, and how to keep ",[15,14669,14670],{},"tsc"," happy",[11,14673,14674,14675,14678,14679,14682],{},"Generated templates don't exist on disk when the type-checker runs. Without extra work, ",[15,14676,14677],{},"import config from '#build\u002Fapi-provider-config.mjs'"," would be flagged as missing. Each package has a ",[15,14680,14681],{},"nuxt-virtual.d.ts"," declaring stubs:",[41,14684,14686],{"className":187,"code":14685,"language":189,"meta":46,"style":46},"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",[15,14687,14688,14701,14712,14723,14734,14759,14763,14773],{"__ignoreMap":46},[50,14689,14690,14693,14696,14699],{"class":52,"line":53},[50,14691,14692],{"class":196},"declare",[50,14694,14695],{"class":196}," module",[50,14697,14698],{"class":311}," '#build\u002Fapi-provider-config.mjs'",[50,14700,968],{"class":207},[50,14702,14703,14705,14708,14710],{"class":52,"line":59},[50,14704,1439],{"class":196},[50,14706,14707],{"class":217}," config",[50,14709,230],{"class":196},[50,14711,968],{"class":207},[50,14713,14714,14717,14719,14721],{"class":52,"line":65},[50,14715,14716],{"class":226},"    baseURL",[50,14718,230],{"class":196},[50,14720,233],{"class":217},[50,14722,325],{"class":207},[50,14724,14725,14728,14730,14732],{"class":52,"line":71},[50,14726,14727],{"class":226},"    defaultTimeoutMs",[50,14729,230],{"class":196},[50,14731,445],{"class":217},[50,14733,325],{"class":207},[50,14735,14736,14739,14741,14743,14745,14747,14749,14751,14753,14755,14757],{"class":52,"line":77},[50,14737,14738],{"class":226},"    retry",[50,14740,230],{"class":196},[50,14742,2856],{"class":207},[50,14744,11043],{"class":226},[50,14746,230],{"class":196},[50,14748,445],{"class":217},[50,14750,2866],{"class":207},[50,14752,11052],{"class":226},[50,14754,230],{"class":196},[50,14756,445],{"class":217},[50,14758,2876],{"class":207},[50,14760,14761],{"class":52,"line":83},[50,14762,8710],{"class":207},[50,14764,14765,14768,14770],{"class":52,"line":89},[50,14766,14767],{"class":196},"  export",[50,14769,14387],{"class":196},[50,14771,14772],{"class":207}," config;\n",[50,14774,14775],{"class":52,"line":95},[50,14776,170],{"class":207},[11,14778,14779,14780,14783],{},"Now ",[15,14781,14782],{},"tsc --noEmit"," passes, and editor autocomplete still works on fields of the generated config.",[30,14785,14787,14790],{"id":14786},"nuxt-api-provider-chainable-interceptors-and-two-transports",[15,14788,14789],{},"nuxt-api-provider",": chainable interceptors and two transports",[11,14792,14793],{},"The public surface of the API client is deliberately flat. You call it like a function:",[41,14795,14797],{"className":187,"code":14796,"language":189,"meta":46,"style":46},"const users = await $apiProvider\u003CUser[]>('\u002Fusers', { method: 'GET' });\n",[15,14798,14799],{"__ignoreMap":46},[50,14800,14801,14803,14805,14807,14809,14812,14814,14816,14818,14820,14823,14825],{"class":52,"line":53},[50,14802,952],{"class":196},[50,14804,11342],{"class":217},[50,14806,214],{"class":196},[50,14808,4325],{"class":196},[50,14810,14811],{"class":203}," $apiProvider",[50,14813,208],{"class":207},[50,14815,11353],{"class":203},[50,14817,11356],{"class":207},[50,14819,11359],{"class":311},[50,14821,14822],{"class":207},", { method: ",[50,14824,12584],{"class":311},[50,14826,6134],{"class":207},[11,14828,14829],{},"You add cross-cutting behavior through three chains:",[41,14831,14833],{"className":187,"code":14832,"language":189,"meta":46,"style":46},"$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",[15,14834,14835,14852,14868,14872,14876,14901],{"__ignoreMap":46},[50,14836,14837,14840,14842,14844,14846,14848,14850],{"class":52,"line":53},[50,14838,14839],{"class":207},"$apiProvider.",[50,14841,10707],{"class":203},[50,14843,1676],{"class":207},[50,14845,10314],{"class":226},[50,14847,440],{"class":207},[50,14849,362],{"class":196},[50,14851,968],{"class":207},[50,14853,14854,14857,14859,14862,14864,14866],{"class":52,"line":59},[50,14855,14856],{"class":207},"  ctx.headers.Authorization ",[50,14858,639],{"class":196},[50,14860,14861],{"class":311}," `Bearer ${",[50,14863,10911],{"class":207},[50,14865,2049],{"class":311},[50,14867,325],{"class":207},[50,14869,14870],{"class":52,"line":65},[50,14871,1153],{"class":207},[50,14873,14874],{"class":52,"line":71},[50,14875,117],{"emptyLinePlaceholder":116},[50,14877,14878,14880,14882,14884,14886,14888,14891,14893,14895,14897,14899],{"class":52,"line":77},[50,14879,14839],{"class":207},[50,14881,10774],{"class":203},[50,14883,1676],{"class":207},[50,14885,10314],{"class":226},[50,14887,284],{"class":207},[50,14889,14890],{"class":226},"response",[50,14892,440],{"class":207},[50,14894,362],{"class":196},[50,14896,2856],{"class":207},[50,14898,14434],{"class":1853},[50,14900,6134],{"class":207},[50,14902,14903,14905,14908,14910,14912,14914,14917,14919,14921,14923,14925],{"class":52,"line":83},[50,14904,14839],{"class":207},[50,14906,14907],{"class":203},"useError",[50,14909,1676],{"class":207},[50,14911,10314],{"class":226},[50,14913,284],{"class":207},[50,14915,14916],{"class":226},"err",[50,14918,440],{"class":207},[50,14920,362],{"class":196},[50,14922,2856],{"class":207},[50,14924,14434],{"class":1853},[50,14926,6134],{"class":207},[11,14928,10847,14929,14931],{},[15,14930,10850],{}," returns an unsubscribe function, which matters if you register interceptors from a component and want to clean up on unmount.",[1349,14933,14935],{"id":14934},"two-transports-one-api","Two transports, one API",[11,14937,14938,14939,14941,14942,14944,14945,14947,14948,14950,14951,14953],{},"Most requests go through ",[15,14940,6704],{},". But ",[15,14943,6704],{}," doesn't expose upload progress — the ",[15,14946,9405],{}," side of the Request body is fine for streams but browsers don't give you byte-level ",[15,14949,8338],{}," events the way XHR does. So when a caller passes ",[15,14952,14197],{},", the client swaps transports:",[41,14955,14957],{"className":187,"code":14956,"language":189,"meta":46,"style":46},"const transport = ctx.options.onRequestProgress\n  ? createXhrFetch(ctx.options.onRequestProgress)\n  : defaultFetch;\n",[15,14958,14959,14971,14981],{"__ignoreMap":46},[50,14960,14961,14963,14966,14968],{"class":52,"line":53},[50,14962,952],{"class":196},[50,14964,14965],{"class":217}," transport",[50,14967,214],{"class":196},[50,14969,14970],{"class":207}," ctx.options.onRequestProgress\n",[50,14972,14973,14976,14978],{"class":52,"line":59},[50,14974,14975],{"class":196},"  ?",[50,14977,12881],{"class":203},[50,14979,14980],{"class":207},"(ctx.options.onRequestProgress)\n",[50,14982,14983,14985],{"class":52,"line":65},[50,14984,6041],{"class":196},[50,14986,14987],{"class":207}," defaultFetch;\n",[11,14989,14990],{},"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.",[1349,14992,14994],{"id":14993},"interceptors-by-path-not-by-function","Interceptors by path, not by function",[11,14996,14997],{},"The module options look like this:",[41,14999,15001],{"className":187,"code":15000,"language":189,"meta":46,"style":46},"{\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",[15,15002,15003,15008,15019,15032,15044,15057],{"__ignoreMap":46},[50,15004,15005],{"class":52,"line":53},[50,15006,15007],{"class":207},"{\n",[50,15009,15010,15012,15015,15017],{"class":52,"line":59},[50,15011,9777],{"class":203},[50,15013,15014],{"class":207},": ",[50,15016,11253],{"class":311},[50,15018,999],{"class":207},[50,15020,15021,15024,15027,15030],{"class":52,"line":65},[50,15022,15023],{"class":203},"  onRequestPath",[50,15025,15026],{"class":207},":  ",[50,15028,15029],{"class":311},"'~\u002Fapi\u002Fon-request.ts'",[50,15031,999],{"class":207},[50,15033,15034,15037,15039,15042],{"class":52,"line":71},[50,15035,15036],{"class":203},"  onSuccessPath",[50,15038,15026],{"class":207},[50,15040,15041],{"class":311},"'~\u002Fapi\u002Fon-response.ts'",[50,15043,999],{"class":207},[50,15045,15046,15049,15052,15055],{"class":52,"line":77},[50,15047,15048],{"class":203},"  onErrorPath",[50,15050,15051],{"class":207},":    ",[50,15053,15054],{"class":311},"'~\u002Fapi\u002Fon-error.ts'",[50,15056,999],{"class":207},[50,15058,15059],{"class":52,"line":83},[50,15060,170],{"class":207},[11,15062,15063,15064,15067],{},"Interceptors are resolved as ",[504,15065,15066],{},"file paths",", not inline functions. The generated template dynamically imports them, and the runtime plugin wires whatever's exported into the chain. Two reasons:",[2163,15069,15070,15080],{},[2166,15071,15072,15075,15076,15079],{},[2169,15073,15074],{},"No circular config."," Users often want to import types from the api-provider module inside their interceptor. If the interceptor lived inside ",[15,15077,15078],{},"nuxt.config.ts",", the config file would depend on the module it's configuring.",[2166,15081,15082,15085],{},[2169,15083,15084],{},"Code-splitting."," The interceptor becomes its own chunk, which matters on cold loads.",[1349,15087,15089,15090],{"id":15088},"error-branding-without-instanceof","Error branding without ",[15,15091,15092],{},"instanceof",[11,15094,15095,15097],{},[15,15096,15092],{}," fails the moment you have two copies of the same class — which happens with duplicated deps, iframes, web workers, or pnpm hoisting quirks. The client's error type uses a Symbol brand instead:",[41,15099,15101],{"className":187,"code":15100,"language":189,"meta":46,"style":46},"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",[15,15102,15103,15128,15133,15137,15141,15156,15174,15178,15206,15235,15239],{"__ignoreMap":46},[50,15104,15105,15107,15110,15112,15115,15118,15120,15123,15126],{"class":52,"line":53},[50,15106,952],{"class":196},[50,15108,15109],{"class":217}," API_ERROR_BRAND",[50,15111,230],{"class":196},[50,15113,15114],{"class":203}," unique",[50,15116,15117],{"class":217}," symbol",[50,15119,214],{"class":196},[50,15121,15122],{"class":207}," Symbol.",[50,15124,15125],{"class":203},"for",[50,15127,979],{"class":207},[50,15129,15130],{"class":52,"line":59},[50,15131,15132],{"class":311},"  '@alikhalilll\u002Fnuxt-api-provider.ApiError'\n",[50,15134,15135],{"class":52,"line":65},[50,15136,1971],{"class":207},[50,15138,15139],{"class":52,"line":71},[50,15140,117],{"emptyLinePlaceholder":116},[50,15142,15143,15145,15147,15149,15152,15154],{"class":52,"line":77},[50,15144,197],{"class":196},[50,15146,4999],{"class":196},[50,15148,9708],{"class":203},[50,15150,15151],{"class":196}," extends",[50,15153,6503],{"class":203},[50,15155,968],{"class":207},[50,15157,15158,15161,15163,15166,15168,15170,15172],{"class":52,"line":83},[50,15159,15160],{"class":196},"  readonly",[50,15162,1106],{"class":207},[50,15164,15165],{"class":217},"API_ERROR_BRAND",[50,15167,7834],{"class":207},[50,15169,639],{"class":196},[50,15171,2936],{"class":217},[50,15173,325],{"class":207},[50,15175,15176],{"class":52,"line":89},[50,15177,117],{"emptyLinePlaceholder":116},[50,15179,15180,15183,15185,15187,15189,15191,15193,15195,15197,15200,15202,15204],{"class":52,"line":95},[50,15181,15182],{"class":196},"  static",[50,15184,5680],{"class":203},[50,15186,1073],{"class":207},[50,15188,11599],{"class":226},[50,15190,230],{"class":196},[50,15192,218],{"class":217},[50,15194,1643],{"class":207},[50,15196,230],{"class":196},[50,15198,15199],{"class":226}," e",[50,15201,5680],{"class":196},[50,15203,9708],{"class":203},[50,15205,968],{"class":207},[50,15207,15208,15210,15212,15215,15217,15219,15221,15223,15225,15227,15229,15231,15233],{"class":52,"line":101},[50,15209,1558],{"class":196},[50,15211,1291],{"class":196},[50,15213,15214],{"class":207}," e ",[50,15216,1297],{"class":196},[50,15218,1715],{"class":311},[50,15220,2423],{"class":196},[50,15222,15214],{"class":207},[50,15224,1949],{"class":196},[50,15226,4667],{"class":217},[50,15228,2423],{"class":196},[50,15230,15109],{"class":217},[50,15232,1936],{"class":196},[50,15234,11148],{"class":207},[50,15236,15237],{"class":52,"line":107},[50,15238,110],{"class":207},[50,15240,15241],{"class":52,"line":113},[50,15242,170],{"class":207},[11,15244,15245,15248,15249,15251,15252,15255,15256,15259],{},[15,15246,15247],{},"Symbol.for"," gives you the ",[504,15250,7323],{}," symbol across module copies. ",[15,15253,15254],{},"ApiError.is(err)"," works where ",[15,15257,15258],{},"err instanceof ApiError"," doesn't. This has saved me on every project that ever crossed a realm boundary.",[30,15261,15263,15266],{"id":15262},"nuxt-auto-middleware-compile-time-regex-runtime-dispatch",[15,15264,15265],{},"nuxt-auto-middleware",": compile-time regex, runtime dispatch",[11,15268,15269],{},"The module takes rules like this:",[41,15271,15273],{"className":187,"code":15272,"language":189,"meta":46,"style":46},"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",[15,15274,15275,15283,15290,15309,15313,15321,15338,15371,15376],{"__ignoreMap":46},[50,15276,15277,15280],{"class":52,"line":53},[50,15278,15279],{"class":203},"autoMiddleware",[50,15281,15282],{"class":207},": {\n",[50,15284,15285,15288],{"class":52,"line":59},[50,15286,15287],{"class":203},"  groups",[50,15289,15282],{"class":207},[50,15291,15292,15295,15298,15301,15303,15306],{"class":52,"line":65},[50,15293,15294],{"class":203},"    adminOnly",[50,15296,15297],{"class":207},": [",[50,15299,15300],{"class":311},"'auth'",[50,15302,284],{"class":207},[50,15304,15305],{"class":311},"'require-admin'",[50,15307,15308],{"class":207},"],\n",[50,15310,15311],{"class":52,"line":71},[50,15312,3646],{"class":207},[50,15314,15315,15318],{"class":52,"line":77},[50,15316,15317],{"class":203},"  rules",[50,15319,15320],{"class":207},": [\n",[50,15322,15323,15326,15329,15332,15335],{"class":52,"line":83},[50,15324,15325],{"class":207},"    { layouts: [",[50,15327,15328],{"class":311},"'admin-*'",[50,15330,15331],{"class":207},"], middlewares: [",[50,15333,15334],{"class":311},"'@adminOnly'",[50,15336,15337],{"class":207},"] },\n",[50,15339,15340,15342,15344,15346,15350,15354,15356,15358,15360,15362,15364,15366,15369],{"class":52,"line":89},[50,15341,15325],{"class":207},[50,15343,7205],{"class":311},[50,15345,7247],{"class":196},[50,15347,15349],{"class":15348},"sA_wV","workspace",[50,15351,15353],{"class":15352},"snhLl","\\\u002F",[50,15355,181],{"class":217},[50,15357,11184],{"class":196},[50,15359,7205],{"class":311},[50,15361,15331],{"class":207},[50,15363,15300],{"class":311},[50,15365,284],{"class":207},[50,15367,15368],{"class":311},"'workspace'",[50,15370,15337],{"class":207},[50,15372,15373],{"class":52,"line":95},[50,15374,15375],{"class":207},"  ],\n",[50,15377,15378],{"class":52,"line":101},[50,15379,170],{"class":207},[11,15381,15382,15383,15386],{},"At module setup time, each glob gets compiled to a RegExp. Each group reference gets expanded. The resulting rules get ",[504,15384,15385],{},"serialized"," into a generated template:",[41,15388,15390],{"className":187,"code":15389,"language":189,"meta":46,"style":46},"export const rules = [\n  { patterns: ['^admin-.*$'], middlewares: ['auth', 'require-admin'] },\n  { patterns: ['^workspace\\\\\u002F.*'], middlewares: ['auth', 'workspace'] },\n];\n",[15,15391,15392,15406,15424,15447],{"__ignoreMap":46},[50,15393,15394,15396,15398,15401,15403],{"class":52,"line":53},[50,15395,197],{"class":196},[50,15397,5606],{"class":196},[50,15399,15400],{"class":217}," rules",[50,15402,214],{"class":196},[50,15404,15405],{"class":207}," [\n",[50,15407,15408,15411,15414,15416,15418,15420,15422],{"class":52,"line":59},[50,15409,15410],{"class":207},"  { patterns: [",[50,15412,15413],{"class":311},"'^admin-.*$'",[50,15415,15331],{"class":207},[50,15417,15300],{"class":311},[50,15419,284],{"class":207},[50,15421,15305],{"class":311},[50,15423,15337],{"class":207},[50,15425,15426,15428,15431,15434,15437,15439,15441,15443,15445],{"class":52,"line":65},[50,15427,15410],{"class":207},[50,15429,15430],{"class":311},"'^workspace",[50,15432,15433],{"class":217},"\\\\",[50,15435,15436],{"class":311},"\u002F.*'",[50,15438,15331],{"class":207},[50,15440,15300],{"class":311},[50,15442,284],{"class":207},[50,15444,15368],{"class":311},[50,15446,15337],{"class":207},[50,15448,15449],{"class":52,"line":71},[50,15450,5068],{"class":207},[11,15452,15453,15454,15457],{},"At runtime, the plugin rehydrates the patterns with ",[15,15455,15456],{},"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.",[30,15459,15461,15464],{"id":15460},"nuxt-crypto-the-lru-that-caches-promises",[15,15462,15463],{},"nuxt-crypto",": the LRU that caches promises",[11,15466,15467],{},"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,15469,15470,15471,15474],{},"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 ",[504,15472,15473],{},"what"," it caches:",[41,15476,15478],{"className":187,"code":15477,"language":189,"meta":46,"style":46},"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",[15,15479,15480,15509,15526,15542,15554,15558,15563,15580,15585,15590,15595,15600,15605,15609,15619,15626],{"__ignoreMap":46},[50,15481,15482,15484,15487,15489,15491,15493,15496,15498,15501,15503,15505,15507],{"class":52,"line":53},[50,15483,952],{"class":196},[50,15485,15486],{"class":203}," getDerivedKey",[50,15488,214],{"class":196},[50,15490,4697],{"class":196},[50,15492,420],{"class":207},[50,15494,15495],{"class":226},"salt",[50,15497,284],{"class":207},[50,15499,15500],{"class":226},"fingerprint",[50,15502,987],{"class":196},[50,15504,440],{"class":207},[50,15506,362],{"class":196},[50,15508,968],{"class":207},[50,15510,15511,15513,15516,15518,15521,15523],{"class":52,"line":59},[50,15512,1439],{"class":196},[50,15514,15515],{"class":217}," key",[50,15517,214],{"class":196},[50,15519,15520],{"class":207}," KeyCache.",[50,15522,520],{"class":203},[50,15524,15525],{"class":207},"(salt, iterations, fingerprint);\n",[50,15527,15528,15530,15532,15534,15537,15539],{"class":52,"line":65},[50,15529,1439],{"class":196},[50,15531,6332],{"class":217},[50,15533,214],{"class":196},[50,15535,15536],{"class":207}," cache.",[50,15538,7028],{"class":203},[50,15540,15541],{"class":207},"(key);\n",[50,15543,15544,15546,15549,15551],{"class":52,"line":71},[50,15545,1270],{"class":196},[50,15547,15548],{"class":207}," (cached) ",[50,15550,1281],{"class":196},[50,15552,15553],{"class":207}," cached;\n",[50,15555,15556],{"class":52,"line":77},[50,15557,117],{"emptyLinePlaceholder":116},[50,15559,15560],{"class":52,"line":83},[50,15561,15562],{"class":1853},"  \u002F\u002F Cache the promise, not the settled key\n",[50,15564,15565,15567,15570,15572,15575,15578],{"class":52,"line":89},[50,15566,1439],{"class":196},[50,15568,15569],{"class":217}," pending",[50,15571,214],{"class":196},[50,15573,15574],{"class":207}," algorithm.",[50,15576,15577],{"class":203},"deriveKey",[50,15579,11245],{"class":207},[50,15581,15582],{"class":52,"line":95},[50,15583,15584],{"class":207},"    subtle,\n",[50,15586,15587],{"class":52,"line":101},[50,15588,15589],{"class":207},"    passphrase,\n",[50,15591,15592],{"class":52,"line":107},[50,15593,15594],{"class":207},"    fingerprint,\n",[50,15596,15597],{"class":52,"line":113},[50,15598,15599],{"class":207},"    salt,\n",[50,15601,15602],{"class":52,"line":120},[50,15603,15604],{"class":207},"    iterations,\n",[50,15606,15607],{"class":52,"line":126},[50,15608,1499],{"class":207},[50,15610,15611,15614,15616],{"class":52,"line":132},[50,15612,15613],{"class":207},"  cache.",[50,15615,2316],{"class":203},[50,15617,15618],{"class":207},"(key, pending);\n",[50,15620,15621,15623],{"class":52,"line":138},[50,15622,973],{"class":196},[50,15624,15625],{"class":207}," pending;\n",[50,15627,15628],{"class":52,"line":144},[50,15629,1319],{"class":207},[11,15631,15632,15633,4964,15636,15639,15640,15643,15644,15646],{},"The cache holds ",[15,15634,15635],{},"Promise\u003CCryptoKey>",[15,15637,15638],{},"CryptoKey",". If three ",[15,15641,15642],{},"decrypt()"," calls arrive in the same tick with the same salt, they all await the ",[504,15645,7323],{}," 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,15648,15649,15650,15652],{},"The LRU itself uses the fact that JavaScript ",[15,15651,6805],{}," preserves insertion order:",[41,15654,15656],{"className":187,"code":15655,"language":189,"meta":46,"style":46},"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",[15,15657,15658,15680,15698,15715,15731,15742,15749],{"__ignoreMap":46},[50,15659,15660,15662,15665,15668,15670,15672,15674,15676,15678],{"class":52,"line":53},[50,15661,7028],{"class":203},[50,15663,15664],{"class":207},"(key: string): ",[50,15666,15667],{"class":217},"Promise",[50,15669,208],{"class":196},[50,15671,15638],{"class":207},[50,15673,11966],{"class":196},[50,15675,236],{"class":196},[50,15677,1752],{"class":217},[50,15679,968],{"class":207},[50,15681,15682,15684,15686,15688,15691,15694,15696],{"class":52,"line":59},[50,15683,1439],{"class":196},[50,15685,5677],{"class":217},[50,15687,214],{"class":196},[50,15689,15690],{"class":217}," this",[50,15692,15693],{"class":207},".map.",[50,15695,7028],{"class":203},[50,15697,15541],{"class":207},[50,15699,15700,15702,15704,15706,15709,15711,15713],{"class":52,"line":65},[50,15701,1270],{"class":196},[50,15703,420],{"class":207},[50,15705,1275],{"class":196},[50,15707,15708],{"class":207},"value) ",[50,15710,1281],{"class":196},[50,15712,1752],{"class":217},[50,15714,325],{"class":207},[50,15716,15717,15720,15722,15725,15728],{"class":52,"line":71},[50,15718,15719],{"class":217},"  this",[50,15721,15693],{"class":207},[50,15723,15724],{"class":203},"delete",[50,15726,15727],{"class":207},"(key);   ",[50,15729,15730],{"class":1853},"\u002F\u002F delete + re-insert moves to newest\n",[50,15732,15733,15735,15737,15739],{"class":52,"line":77},[50,15734,15719],{"class":217},[50,15736,15693],{"class":207},[50,15738,2316],{"class":203},[50,15740,15741],{"class":207},"(key, value);\n",[50,15743,15744,15746],{"class":52,"line":83},[50,15745,973],{"class":196},[50,15747,15748],{"class":207}," value;\n",[50,15750,15751],{"class":52,"line":89},[50,15752,170],{"class":207},[11,15754,15755,15756,15759],{},"Eviction is then just ",[15,15757,15758],{},"this.map.keys().next().value"," — the oldest.",[1349,15761,15763],{"id":15762},"versioned-payloads","Versioned payloads",[11,15765,15766],{},"Encrypt output carries a version byte:",[41,15768,15772],{"className":15769,"code":15771,"language":9718},[15770],"language-text","[version: 1B] [salt: 16B] [iv: 12B] [ciphertext: …]\n",[15,15773,15771],{"__ignoreMap":46},[11,15775,15776],{},"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.",[30,15778,15780],{"id":15779},"the-docs-site-as-a-playground-for-the-modules","The docs site as a playground for the modules",[11,15782,15783,15786,15787,15790],{},[15,15784,15785],{},"apps\u002Fdocs"," uses all three modules. That sounds obvious, but it's load-bearing — the docs are ",[504,15788,15789],{},"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,15792,15793],{},"Other things that earned their keep:",[2163,15795,15796,15808,15822],{},[2166,15797,15798,15803,15804,15807],{},[2169,15799,15800],{},[15,15801,15802],{},"@tailwindcss\u002Fvite"," — no PostCSS, no ",[15,15805,15806],{},"tailwind.config.js",", just a Vite plugin. One less config file in my life.",[2166,15809,15810,15813,15814,15817,15818,15821],{},[2169,15811,15812],{},"Static prerender to GitHub Pages"," — Nitro's ",[15,15815,15816],{},"github-pages"," preset gives you a ",[15,15819,15820],{},"\u002F.output\u002Fpublic\u002F"," that Actions uploads straight to Pages. No servers, no cache invalidation, no surprises.",[2166,15823,15824,15829],{},[2169,15825,15826],{},[15,15827,15828],{},"NUXT_PUBLIC_CF_ANALYTICS_TOKEN"," — optional Cloudflare Web Analytics. No cookies, no banner, no opinion.",[30,15831,15833],{"id":15832},"release-changesets-all-the-way-down","Release: Changesets all the way down",[11,15835,15836,15837,15840,15841,15844],{},"Every PR that changes a package includes a ",[15,15838,15839],{},".changeset\u002F\u003Cid>.md"," describing the bump and changelog entry. On master, a GitHub Action runs ",[15,15842,15843],{},"changesets version"," and either:",[9424,15846,15847,15850],{},[2166,15848,15849],{},"Opens a \"Version Packages\" PR with the bumped versions, or",[2166,15851,15852],{},"Publishes to npm if versions are already bumped.",[11,15854,15855,15856,15859,15860,15863,15864,15867],{},"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: ",[15,15857,15858],{},"ci.yml"," (lint + typecheck + matrix build), ",[15,15861,15862],{},"release.yml"," (changesets), and ",[15,15865,15866],{},"commitlint.yml"," (conventional commits on PRs).",[30,15869,15871],{"id":15870},"the-parts-id-reuse-tomorrow","The parts I'd reuse tomorrow",[11,15873,15874],{},"If I were starting a new Nuxt module from scratch, I'd lift these patterns without thinking:",[2163,15876,15877,15887,15897,15903,15909],{},[2166,15878,15879,15886],{},[2169,15880,15881,15882,15885],{},"Serialize config into a generated ",[15,15883,15884],{},".mjs"," file."," Avoids re-evaluating at runtime, and the runtime plugin stays 15 lines.",[2166,15888,15889,15896],{},[2169,15890,15891,15892,15895],{},"Virtual-module ",[15,15893,15894],{},".d.ts"," stubs."," Offline typecheck without running the Nuxt prepare step.",[2166,15898,15899,15902],{},[2169,15900,15901],{},"Symbol-branded errors."," Free for low-traffic modules, life-saving for the ones that cross realms.",[2166,15904,15905,15908],{},[2169,15906,15907],{},"Promise-keyed caches."," Any time the underlying operation is async and expensive, cache the promise, not the result.",[2166,15910,15911,15914],{},[2169,15912,15913],{},"Framework-agnostic core + thin Nuxt wrapper."," Makes the code easier to test, easier to reuse, and easier to delete.",[11,15916,15917,15918,15924],{},"The repo is MIT-licensed and lives on ",[3208,15919,15923],{"href":15920,"rel":15921},"https:\u002F\u002Fgithub.com\u002Falikhalilll",[15922],"nofollow","GitHub",". If you spot something that could be better, open an issue — I'd genuinely rather be corrected than comfortable.",[4011,15926,15927],{},"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":46,"searchDepth":59,"depth":59,"links":15929},[15930,15931,15935,15942,15944,15948,15949,15950],{"id":14296,"depth":59,"text":14297},{"id":14371,"depth":59,"text":14372,"children":15932},[15933],{"id":14666,"depth":65,"text":15934},"Virtual modules, and how to keep tsc happy",{"id":14786,"depth":59,"text":15936,"children":15937},"nuxt-api-provider: chainable interceptors and two transports",[15938,15939,15940],{"id":14934,"depth":65,"text":14935},{"id":14993,"depth":65,"text":14994},{"id":15088,"depth":65,"text":15941},"Error branding without instanceof",{"id":15262,"depth":59,"text":15943},"nuxt-auto-middleware: compile-time regex, runtime dispatch",{"id":15460,"depth":59,"text":15945,"children":15946},"nuxt-crypto: the LRU that caches promises",[15947],{"id":15762,"depth":65,"text":15763},{"id":15779,"depth":59,"text":15780},{"id":15832,"depth":59,"text":15833},{"id":15870,"depth":59,"text":15871},"2026-04-18","Three Nuxt 4 modules in a pnpm monorepo — a typed fetch client with upload progress, AES-GCM + PBKDF2 crypto, and layout-scoped middleware.",[15954,15955,5953,15956,15957,14272,14269,15958,15959,15960,15961,15962],"Nuxt 4","Nuxt modules","pnpm monorepo","fetch client","AES-GCM","PBKDF2","Web Crypto","route middleware","Changesets",{},"\u002Fblog\u002Fbuilding-ali-nuxt-toolkit",{"title":14282,"description":15952},"blog\u002Fbuilding-ali-nuxt-toolkit","HMNGIjxECn8jb_p51_NhjcYSo0K54UVJVby598l78G4",{"id":15969,"title":15970,"body":15971,"date":16194,"description":16195,"draft":4039,"extension":4040,"image":4041,"keywords":16196,"lang":4041,"meta":16202,"navigation":116,"path":16203,"seo":16204,"stem":16205,"updatedAt":4041,"__hash__":16206},"blog\u002Fblog\u002Fvalidating-image-urls-without-complex-error-handling.md","Validating image URLs without complex error handling",{"type":8,"value":15972,"toc":16189},[15973,15982,15989,15993,15996,16025,16029,16045,16070,16073,16077,16080,16151,16175,16178,16186],[11,15974,15975,15976,15978,15979],{},"If you've 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 ",[15,15977,10283],{},", or you can ask the browser directly: ",[504,15980,15981],{},"can you load this?",[11,15983,15984,15985,15988],{},"That's what ",[15,15986,15987],{},"checkUrl"," does. It returns a Promise that resolves when the image loads and rejects when it doesn't — no fetch, no CORS fiddling, no manual HEAD requests.",[30,15990,15992],{"id":15991},"the-idea","The idea",[11,15994,15995],{},"A Promise that resolves or rejects based on whether the image loads:",[41,15997,15999],{"className":43,"code":15998,"language":45,"meta":46,"style":46},"\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",[15,16000,16001,16006,16011,16015,16020],{"__ignoreMap":46},[50,16002,16003],{"class":52,"line":53},[50,16004,16005],{},"\u002F\u002F success\n",[50,16007,16008],{"class":52,"line":59},[50,16009,16010],{},"const promise = new Promise\u003Cvoid>((resolve, reject) => { resolve() })\n",[50,16012,16013],{"class":52,"line":65},[50,16014,117],{"emptyLinePlaceholder":116},[50,16016,16017],{"class":52,"line":71},[50,16018,16019],{},"\u002F\u002F failure\n",[50,16021,16022],{"class":52,"line":77},[50,16023,16024],{},"const promise = new Promise\u003Cvoid>((resolve, reject) => { reject() })\n",[30,16026,16028],{"id":16027},"using-the-built-in-image-object","Using the built-in Image object",[11,16030,16031,16032,16035,16036,5889,16038,16040,16041,16044],{},"Inside the callback, create an ",[15,16033,16034],{},"Image",", wire up ",[15,16037,11651],{},[15,16039,11672],{},", then set ",[15,16042,16043],{},"src"," to kick off the request.",[41,16046,16048],{"className":43,"code":16047,"language":45,"meta":46,"style":46},"const img = new Image();\nimg.onload = () => resolve();\nimg.onerror = () => reject();\nimg.src = url;\n",[15,16049,16050,16055,16060,16065],{"__ignoreMap":46},[50,16051,16052],{"class":52,"line":53},[50,16053,16054],{},"const img = new Image();\n",[50,16056,16057],{"class":52,"line":59},[50,16058,16059],{},"img.onload = () => resolve();\n",[50,16061,16062],{"class":52,"line":65},[50,16063,16064],{},"img.onerror = () => reject();\n",[50,16066,16067],{"class":52,"line":71},[50,16068,16069],{},"img.src = url;\n",[11,16071,16072],{},"That's the whole mechanism. The browser does the work; the Promise is just a thin wrapper around two events.",[30,16074,16076],{"id":16075},"using-it-with-a-fallback","Using it with a fallback",[11,16078,16079],{},"The most common use case is swapping to a fallback when the original URL is broken.",[41,16081,16083],{"className":43,"code":16082,"language":45,"meta":46,"style":46},"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",[15,16084,16085,16090,16095,16099,16104,16109,16114,16119,16124,16129,16134,16138,16142,16146],{"__ignoreMap":46},[50,16086,16087],{"class":52,"line":53},[50,16088,16089],{},"const fallbackImage = 'https:\u002F\u002Fexample.com\u002Ffallback.png';\n",[50,16091,16092],{"class":52,"line":59},[50,16093,16094],{},"let url: string = anonymousOBJECT.image;\n",[50,16096,16097],{"class":52,"line":65},[50,16098,117],{"emptyLinePlaceholder":116},[50,16100,16101],{"class":52,"line":71},[50,16102,16103],{},"const checkImage = async () => {\n",[50,16105,16106],{"class":52,"line":77},[50,16107,16108],{},"  try {\n",[50,16110,16111],{"class":52,"line":83},[50,16112,16113],{},"    await checkUrl(url);\n",[50,16115,16116],{"class":52,"line":89},[50,16117,16118],{},"    \u002F\u002F valid — keep the original\n",[50,16120,16121],{"class":52,"line":95},[50,16122,16123],{},"  } catch {\n",[50,16125,16126],{"class":52,"line":101},[50,16127,16128],{},"    \u002F\u002F invalid — swap in the fallback\n",[50,16130,16131],{"class":52,"line":107},[50,16132,16133],{},"    url = fallbackImage;\n",[50,16135,16136],{"class":52,"line":113},[50,16137,110],{},[50,16139,16140],{"class":52,"line":120},[50,16141,1319],{},[50,16143,16144],{"class":52,"line":126},[50,16145,117],{"emptyLinePlaceholder":116},[50,16147,16148],{"class":52,"line":132},[50,16149,16150],{},"checkImage();\n",[41,16152,16156],{"className":16153,"code":16154,"language":16155,"meta":46,"style":46},"language-html shiki shiki-themes github-light github-dark","\u003Cimg :src=\"url\" \u002F>\n","html",[15,16157,16158],{"__ignoreMap":46},[50,16159,16160,16162,16165,16168,16170,16173],{"class":52,"line":53},[50,16161,208],{"class":207},[50,16163,16164],{"class":599},"img",[50,16166,16167],{"class":203}," :src",[50,16169,639],{"class":207},[50,16171,16172],{"class":311},"\"url\"",[50,16174,1195],{"class":207},[11,16176,16177],{},"No custom error handling in the template, no flicker of a broken image — just a resolved URL by the time it hits the DOM.",[11,16179,16180,16181,181],{},"Originally published on ",[3208,16182,16185],{"href":16183,"rel":16184},"https:\u002F\u002Fwww.linkedin.com\u002Fpulse\u002Fwithout-having-write-complex-error-handling-code-image-ali-abdelbaqy\u002F",[15922],"LinkedIn",[4011,16187,16188],{},"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":46,"searchDepth":59,"depth":59,"links":16190},[16191,16192,16193],{"id":15991,"depth":59,"text":15992},{"id":16027,"depth":59,"text":16028},{"id":16075,"depth":59,"text":16076},"2023-07-11","A tiny async helper that uses the browser's Image object to tell you whether a URL actually resolves to a loadable image — no fetch, no CORS.",[16197,16198,4228,15667,16199,16200,16201,5953],"JavaScript","image validation","fallback image","Image object","browser API",{},"\u002Fblog\u002Fvalidating-image-urls-without-complex-error-handling",{"title":15970,"description":16195},"blog\u002Fvalidating-image-urls-without-complex-error-handling","PUosmGnIJIKRcLm3szJW_FODClzmaToNyoM9o0v3Xlc",1776596058922]