@octane-xplat/files 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ import { FileInput } from "./FileInput.web.js";
2
+ export { FileInput };
@@ -0,0 +1,263 @@
1
+ import { cx, formatFileSize, normalizePicked, validateFiles } from "./file-input-utils.js";
2
+ import { Fragment, __s, bag5, clone, createElement, createScopedElement, createScopedValue, delegateEvents, evt1, hookSlots, positionalChildren, setEventHandler, setStyle, template, useId, useImperativeHandle, useRef, useState, withSlot } from "octane";
3
+ import { bindSignalAttribute, bindSignalChild, createElementFromConfig, enableSignalBindings } from "octane/internal/client";
4
+ import { Field, Icon, Spinner, Tooltip, useFieldControl } from "@octane-xplat/ui";
5
+ //#region src/FileInput.web.tsrx
6
+ enableSignalBindings(1, true);
7
+ delegateEvents([
8
+ "dragenter",
9
+ "dragleave",
10
+ "dragover",
11
+ "drop"
12
+ ]);
13
+ var _t$0 = /* @__PURE__ */ template("<div></div>");
14
+ var _hs$ = /* @__PURE__ */ hookSlots(9);
15
+ var _h$0 = /* @__PURE__ */ Symbol(_hs$);
16
+ var _h$7 = /* @__PURE__ */ Symbol(_hs$ + 7);
17
+ var STATUS_GLYPHS = {
18
+ error: "⚠",
19
+ warning: "⚠",
20
+ success: "✓"
21
+ };
22
+ var FileInputControl = /* @__PURE__ */ __s(function FileInputControl(inputProps, __s, __extra) {
23
+ const { props } = withSlot(_h$0, useFieldControl, inputProps);
24
+ const generatedId = useId(_hs$ + 1);
25
+ const inputId = props.id ?? generatedId;
26
+ const fileInput = useRef(null, _hs$ + 2);
27
+ const [dragging, setDragging] = useState(false, _hs$ + 3);
28
+ const [busy, setBusy] = useState(false, _hs$ + 4);
29
+ const [validationErrors, setValidationErrors] = useState([], _hs$ + 5);
30
+ const files = props.value == null ? [] : Array.isArray(props.value) ? props.value : [props.value];
31
+ const isDisabled = !!props.isDisabled || !!props.isReadOnly;
32
+ const isBusy = !!props.isLoading || busy;
33
+ const canClear = files.length > 0 && !isDisabled && !isBusy;
34
+ const showsDisabledReason = isDisabled && !!props.disabledMessage;
35
+ const statusType = validationErrors.length ? "error" : props.status?.type;
36
+ const statusMessage = validationErrors.length ? validationErrors.join(" ") : props.status?.message;
37
+ const emitValue = (next) => props.isMultiple ? next : next[0] ?? null;
38
+ const fireChange = (next) => {
39
+ const emitted = emitValue(next);
40
+ props.onChange?.(emitted);
41
+ if (props.changeAction) {
42
+ setBusy(true);
43
+ Promise.resolve(props.changeAction(emitted)).finally(() => setBusy(false));
44
+ }
45
+ };
46
+ const acceptRefs = (picked) => {
47
+ if (isDisabled || isBusy || !picked.length) return;
48
+ const merged = props.isMultiple ? [...files, ...picked] : picked;
49
+ const { valid, errors } = validateFiles(merged, {
50
+ accept: props.accept,
51
+ maxSize: props.maxSize,
52
+ maxFiles: props.maxFiles
53
+ });
54
+ setValidationErrors(errors);
55
+ const next = !props.isMultiple && errors.length ? files : valid;
56
+ if (next.length !== files.length || next.some((file, index) => file.uri !== files[index]?.uri)) fireChange(next);
57
+ };
58
+ const removeAt = (index) => {
59
+ if (isDisabled || isBusy) return;
60
+ setValidationErrors([]);
61
+ fireChange(files.filter((_, i) => i !== index));
62
+ };
63
+ const pick = () => {
64
+ if (isDisabled || isBusy) return;
65
+ if (props.pick) {
66
+ Promise.resolve(props.pick({
67
+ accept: props.accept,
68
+ multiple: props.isMultiple
69
+ })).then((r) => acceptRefs(normalizePicked(r))).catch((error) => setValidationErrors([String(error?.message ?? error)]));
70
+ return;
71
+ }
72
+ fileInput.current?.click();
73
+ };
74
+ const handleFileSelect = (e) => {
75
+ const list = e.target.files;
76
+ const picked = [];
77
+ if (list) for (let i = 0; i < list.length; i++) {
78
+ const f = list[i];
79
+ picked.push({
80
+ name: f.name,
81
+ uri: URL.createObjectURL(f),
82
+ size: f.size,
83
+ mimeType: f.type,
84
+ file: f
85
+ });
86
+ }
87
+ e.target.value = "";
88
+ acceptRefs(picked);
89
+ };
90
+ const handleDrop = (e) => {
91
+ e.preventDefault();
92
+ e.stopPropagation();
93
+ setDragging(false);
94
+ if (isDisabled || isBusy) return;
95
+ const list = e.dataTransfer?.files;
96
+ if (!list?.length) return;
97
+ const picked = [];
98
+ for (let i = 0; i < list.length; i++) {
99
+ const f = list[i];
100
+ picked.push({
101
+ name: f.name,
102
+ uri: URL.createObjectURL(f),
103
+ size: f.size,
104
+ mimeType: f.type,
105
+ file: f
106
+ });
107
+ }
108
+ acceptRefs(picked);
109
+ };
110
+ const dropzone = props.mode === "dropzone";
111
+ const itemContent = createScopedElement(Fragment, {}, () => positionalChildren([
112
+ files.length ? createScopedElement("ul", { className: "vx-fileinput-list" }, () => files.map((f, i) => createScopedElement("li", {
113
+ key: `${f.uri}-${i}`,
114
+ className: "vx-fileinput-item"
115
+ }, () => positionalChildren([
116
+ createElementFromConfig("c:b0d78e7b", Icon, {
117
+ name: "xplat-upload",
118
+ size: 14
119
+ }),
120
+ createScopedElement("span", { className: "vx-fileinput-itemname" }, () => f.name),
121
+ f.size != null ? createScopedElement("span", { className: "vx-fileinput-itemsize" }, () => formatFileSize(f.size)) : null,
122
+ !isDisabled && !isBusy ? createScopedValue(() => createElement("button", {
123
+ type: "button",
124
+ className: "vx-fileinput-itemremove",
125
+ "aria-label": `Remove ${f.name}`,
126
+ onClick: () => removeAt(i)
127
+ }, "×")) : null
128
+ ])))) : null,
129
+ dropzone ? createScopedElement("div", { className: "vx-fileinput-dropzone-hint" }, () => positionalChildren([
130
+ createElementFromConfig("c:928c8dae", Icon, {
131
+ name: "xplat-upload",
132
+ size: 24
133
+ }),
134
+ createScopedElement("span", {}, () => isDisabled ? "Upload disabled" : "Drag & drop files here or click to browse"),
135
+ props.accept ? createScopedElement("span", { className: "vx-fileinput-accept" }, () => props.accept) : null
136
+ ])) : createScopedValue(() => createScopedElement("button", {
137
+ type: "button",
138
+ id: inputId,
139
+ className: "vx-fileinput-trigger",
140
+ disabled: isDisabled && !showsDisabledReason || void 0,
141
+ "aria-disabled": showsDisabledReason || void 0,
142
+ "aria-describedby": props.ariaDescribedBy,
143
+ "aria-required": props.isRequired && !props.isOptional || void 0,
144
+ "aria-invalid": statusType === "error" || void 0,
145
+ "aria-busy": isBusy || void 0,
146
+ onClick: pick
147
+ }, () => positionalChildren([
148
+ createElementFromConfig("c:a0fe8d9f", Icon, {
149
+ name: "xplat-upload",
150
+ size: 14
151
+ }),
152
+ " ",
153
+ files.length ? "Choose more files" : "Choose files"
154
+ ]))),
155
+ dropzone ? createScopedValue(() => createElement("button", {
156
+ type: "button",
157
+ id: inputId,
158
+ className: "vx-fileinput-trigger vx-fileinput-trigger--hidden",
159
+ disabled: isDisabled && !showsDisabledReason || void 0,
160
+ "aria-disabled": showsDisabledReason || void 0,
161
+ "aria-describedby": props.ariaDescribedBy,
162
+ "aria-required": props.isRequired && !props.isOptional || void 0,
163
+ "aria-invalid": statusType === "error" || void 0,
164
+ "aria-busy": isBusy || void 0,
165
+ onClick: pick
166
+ }, "Browse files")) : null,
167
+ canClear ? createScopedValue(() => createElement("button", {
168
+ type: "button",
169
+ className: "vx-fileinput-clear",
170
+ "aria-label": `Clear ${props.fieldLabel ?? props.label ?? "files"}`,
171
+ onClick: () => {
172
+ setValidationErrors([]);
173
+ fireChange([]);
174
+ }
175
+ }, "×")) : null,
176
+ isBusy ? createElementFromConfig("c:47d00c47", Spinner, {
177
+ busy: true,
178
+ className: "vx-fileinput-spinner"
179
+ }) : null,
180
+ statusType ? props.statusVariant === "tooltip" && statusMessage ? createScopedValue(() => createElementFromConfig("c:86665b9b", Tooltip, {
181
+ trigger: createScopedValue(() => createScopedElement("span", { className: cx("vx-field-icon", `vx-field-icon--${statusType}`) }, () => STATUS_GLYPHS[statusType])),
182
+ content: statusMessage
183
+ })) : createScopedValue(() => createScopedElement("span", { className: cx("vx-field-icon", `vx-field-icon--${statusType}`) }, () => STATUS_GLYPHS[statusType])) : null,
184
+ statusMessage && props.statusVariant !== "tooltip" ? createScopedValue(() => createScopedElement("span", {
185
+ className: cx("vx-field-status", `vx-field-status--${statusType}`),
186
+ role: statusType === "error" ? "alert" : "status"
187
+ }, () => statusMessage)) : null,
188
+ createScopedValue(() => createElement("input", {
189
+ ref: (el) => {
190
+ fileInput.current = el;
191
+ },
192
+ type: "file",
193
+ className: "vx-fileinput-nativeinput",
194
+ accept: props.accept,
195
+ multiple: props.isMultiple || void 0,
196
+ disabled: isDisabled || void 0,
197
+ "aria-hidden": "true",
198
+ tabIndex: -1,
199
+ onChange: handleFileSelect
200
+ }))
201
+ ]));
202
+ useImperativeHandle(props.ref, () => ({
203
+ open: pick,
204
+ native: fileInput.current
205
+ }), void 0, _hs$ + 6);
206
+ if (__s.block.pending && !__s.block.crossRenderUpdate) return;
207
+ let _b = __s.slots[0];
208
+ if (_b === void 0) {
209
+ let _m1;
210
+ const _root = clone(_t$0);
211
+ _m1 = _root;
212
+ setEventHandler(_root, "$$dragover", (e) => {
213
+ e.preventDefault();
214
+ if (!isDisabled && !isBusy) setDragging(true);
215
+ });
216
+ setEventHandler(_root, "$$dragenter", (e) => {
217
+ e.preventDefault();
218
+ });
219
+ evt1(_root, "$$dragleave", setDragging, false);
220
+ setEventHandler(_root, "$$drop", handleDrop);
221
+ _b = bag5(__s, _root, void 0, _m1, void 0, null, void 0);
222
+ } else {
223
+ setEventHandler(_b.b, "$$dragover", (e) => {
224
+ e.preventDefault();
225
+ if (!isDisabled && !isBusy) setDragging(true);
226
+ });
227
+ setEventHandler(_b.b, "$$drop", handleDrop);
228
+ }
229
+ _b.a = bindSignalAttribute(__s, _b.a, _b.b, "class", cx("vx-fileinput", dropzone && "vx-fileinput--dropzone", isDisabled && "vx-fileinput--disabled", isBusy && "vx-fileinput--loading", dragging && "vx-fileinput--dragging", statusType && `vx-fileinput--status-${statusType}`, props.size && `vx-fileinput--size-${props.size}`, props.className), "b:e1094c29", "class");
230
+ {
231
+ const _v = props.style;
232
+ if (_b.c !== _v) {
233
+ setStyle(_b.b, _v, _b.c);
234
+ _b.c = _v;
235
+ }
236
+ }
237
+ _b.f = bindSignalChild(__s, _b.f, 1, _b.b, itemContent, "b:11d99fad", null, void 0, void 0, void 0, true);
238
+ });
239
+ var FileInput = function FileInput(props, __s, __extra) {
240
+ const { inField } = withSlot(_h$7, useFieldControl, props);
241
+ const cid = useId(_hs$ + 8);
242
+ const control = createScopedValue(() => createElementFromConfig("c:afa4c501", FileInputControl, { ...props }));
243
+ if (inField) return control ?? null;
244
+ return createScopedValue(() => createScopedElement(Field, {
245
+ inputID: props.id ?? cid,
246
+ label: props.label,
247
+ isLabelHidden: props.isLabelHidden,
248
+ description: props.description,
249
+ isDisabled: props.isDisabled,
250
+ disabledMessage: props.disabledMessage,
251
+ isReadOnly: props.isReadOnly,
252
+ isLoading: props.isLoading,
253
+ isRequired: props.isRequired,
254
+ isOptional: props.isOptional,
255
+ size: props.size,
256
+ status: props.status,
257
+ statusVariant: props.statusVariant,
258
+ labelTooltip: props.labelTooltip,
259
+ width: props.width
260
+ }, () => control, "c:3f29a6d4"));
261
+ };
262
+ //#endregion
263
+ export { FileInput };
@@ -0,0 +1,45 @@
1
+ //#region src/file-bytes.ts
2
+ /**
3
+ * Shared helpers for the binary file primitives (readBytes/writeBytes/export).
4
+ * DOM-free so the native leaf can use it too.
5
+ */
6
+ /** File names used for sandbox writes and export temp files must be bare
7
+ * names, not paths — a `/`, `\`, or `..` segment escapes the sandbox. */
8
+ function assertSafeFileName(name) {
9
+ if (typeof name !== "string" || name.length === 0 || name === "." || name === ".." || name.includes("/") || name.includes("\\") || name.includes("\0")) throw new Error(`Unsafe file name: ${JSON.stringify(name)}`);
10
+ }
11
+ function fileTooLarge(name, maxBytes) {
12
+ return /* @__PURE__ */ new Error(`${name}: file exceeds the ${maxBytes}-byte limit`);
13
+ }
14
+ /** Named rejection for a second `files.export` while one is in flight. */
15
+ function exportBusy() {
16
+ const error = /* @__PURE__ */ new Error("files.export: an export is already in progress");
17
+ error.name = "ExportBusyError";
18
+ return error;
19
+ }
20
+ function throwIfAborted(signal) {
21
+ if (!signal?.aborted) return;
22
+ const thrower = signal.throwIfAborted;
23
+ if (typeof thrower === "function") thrower.call(signal);
24
+ const error = /* @__PURE__ */ new Error("The operation was aborted");
25
+ error.name = "AbortError";
26
+ throw error;
27
+ }
28
+ /** Concatenate Uint8Array chunks into one exact-length array. */
29
+ function concatBytes(chunks, total) {
30
+ const out = new Uint8Array(total);
31
+ let offset = 0;
32
+ for (const chunk of chunks) {
33
+ out.set(chunk, offset);
34
+ offset += chunk.byteLength;
35
+ }
36
+ return out;
37
+ }
38
+ /** Return an ArrayBuffer covering exactly this view's byte range — native
39
+ * marshallers treat the argument as the whole buffer. */
40
+ function exactBuffer(bytes) {
41
+ if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) return bytes.buffer;
42
+ return bytes.slice().buffer;
43
+ }
44
+ //#endregion
45
+ export { assertSafeFileName, concatBytes, exactBuffer, exportBusy, fileTooLarge, throwIfAborted };
@@ -0,0 +1,52 @@
1
+ //#region src/file-input-utils.ts
2
+ function cx(...values) {
3
+ return values.filter(Boolean).join(" ");
4
+ }
5
+ function normalizePicked(result) {
6
+ if (result == null) return [];
7
+ return Array.isArray(result) ? result : [result];
8
+ }
9
+ function fileAccepts(file, accept) {
10
+ if (!accept) return true;
11
+ const tokens = accept.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
12
+ if (!tokens.length) return true;
13
+ const name = file.name.toLowerCase();
14
+ const mime = (file.mimeType ?? "").toLowerCase();
15
+ return tokens.some((token) => {
16
+ if (token === "*/*") return true;
17
+ if (token.startsWith(".")) return name.endsWith(token);
18
+ if (token.endsWith("/*")) return mime.startsWith(token.slice(0, -1));
19
+ return mime === token;
20
+ });
21
+ }
22
+ function formatFileSize(bytes) {
23
+ if (bytes < 1024) return `${bytes} B`;
24
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
25
+ if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
26
+ return `${(bytes / 1073741824).toFixed(1)} GB`;
27
+ }
28
+ function validateFiles(files, opts) {
29
+ const errors = [];
30
+ const valid = [];
31
+ for (const file of files) {
32
+ if (!fileAccepts(file, opts.accept)) {
33
+ errors.push(`${file.name}: file type not accepted`);
34
+ continue;
35
+ }
36
+ if (opts.maxSize != null && file.size != null && file.size > opts.maxSize) {
37
+ errors.push(`${file.name}: file exceeds ${formatFileSize(opts.maxSize)}`);
38
+ continue;
39
+ }
40
+ valid.push(file);
41
+ }
42
+ if (opts.maxFiles != null && valid.length > opts.maxFiles) {
43
+ const extra = valid.splice(opts.maxFiles);
44
+ for (const file of extra) errors.push(`${file.name}: exceeds the ${opts.maxFiles}-file limit`);
45
+ }
46
+ return {
47
+ valid,
48
+ errors
49
+ };
50
+ }
51
+ //#endregion
52
+ export { cx, fileAccepts, formatFileSize, normalizePicked, validateFiles };
@@ -0,0 +1,2 @@
1
+ import { files } from "./files.web.js";
2
+ export { files };
@@ -0,0 +1,144 @@
1
+ import { assertSafeFileName, concatBytes, exactBuffer, fileTooLarge, throwIfAborted } from "./file-bytes.js";
2
+ import { desktopHost } from "@octane-xplat/platform/host/web";
3
+ //#region src/files.web.ts
4
+ var objectBlobs = /* @__PURE__ */ new Map();
5
+ function mintObjectUrl(blob) {
6
+ const uri = URL.createObjectURL(blob);
7
+ objectBlobs.set(uri, blob);
8
+ return uri;
9
+ }
10
+ function chooseFiles(accept, multiple) {
11
+ return new Promise((resolve) => {
12
+ const input = document.createElement("input");
13
+ input.type = "file";
14
+ input.accept = accept;
15
+ input.multiple = multiple;
16
+ input.onchange = () => {
17
+ const refs = [];
18
+ for (const file of Array.from(input.files ?? [])) refs.push({
19
+ name: file.name,
20
+ uri: mintObjectUrl(file)
21
+ });
22
+ resolve(refs);
23
+ };
24
+ input.oncancel = () => resolve([]);
25
+ input.click();
26
+ });
27
+ }
28
+ async function fetchBytes(uri, opts) {
29
+ const response = await fetch(uri, { signal: opts?.signal });
30
+ if (!response.ok) throw new Error(`Unable to read file: ${uri}`);
31
+ if (opts?.maxBytes == null) return new Uint8Array(await response.arrayBuffer());
32
+ const reader = response.body?.getReader();
33
+ if (!reader) {
34
+ const bytes = new Uint8Array(await response.arrayBuffer());
35
+ if (bytes.byteLength > opts.maxBytes) throw fileTooLarge(uri, opts.maxBytes);
36
+ return bytes;
37
+ }
38
+ const chunks = [];
39
+ let total = 0;
40
+ try {
41
+ while (true) {
42
+ const { done, value } = await reader.read();
43
+ if (done) break;
44
+ total += value.byteLength;
45
+ if (total > opts.maxBytes) {
46
+ await reader.cancel().catch(() => {});
47
+ throw fileTooLarge(uri, opts.maxBytes);
48
+ }
49
+ chunks.push(value);
50
+ }
51
+ } finally {
52
+ reader.releaseLock();
53
+ }
54
+ return concatBytes(chunks, total);
55
+ }
56
+ var files = {
57
+ async pickMultiple(accept = "*/*", _opts) {
58
+ return chooseFiles(accept, true);
59
+ },
60
+ async pick(accept = "*/*", opts) {
61
+ const host = desktopHost();
62
+ if (host && await host.supports("files", "pick")) return host.files.pick(accept, { startingFolder: opts?.startingFolder });
63
+ return (await chooseFiles(accept, false))[0] ?? null;
64
+ },
65
+ async readText(ref) {
66
+ const host = desktopHost();
67
+ if (host && await host.supports("files", "readText")) {
68
+ const text = await host.files.readText(ref.uri);
69
+ if (text === null) throw new Error(`Unable to read host file: ${ref.uri}`);
70
+ return text;
71
+ }
72
+ return (await fetch(ref.uri)).text();
73
+ },
74
+ async readBytes(ref, opts) {
75
+ throwIfAborted(opts?.signal);
76
+ const blob = objectBlobs.get(ref.uri);
77
+ if (blob) {
78
+ if (opts?.maxBytes != null && blob.size > opts.maxBytes) throw fileTooLarge(ref.uri, opts.maxBytes);
79
+ const bytes = new Uint8Array(await blob.arrayBuffer());
80
+ throwIfAborted(opts?.signal);
81
+ return bytes;
82
+ }
83
+ return fetchBytes(ref.uri, opts);
84
+ },
85
+ /** Desktop hosts write the selected path; plain web starts a download. */
86
+ async writeText(name, text) {
87
+ assertSafeFileName(name);
88
+ const host = desktopHost();
89
+ if (host && await host.supports("files", "writeText")) {
90
+ const file = await host.files.writeText(name, text);
91
+ if (!file) throw new Error("Host file save was cancelled");
92
+ return file;
93
+ }
94
+ const uri = mintObjectUrl(new Blob([text], { type: "text/plain" }));
95
+ const a = document.createElement("a");
96
+ a.href = uri;
97
+ a.download = name;
98
+ a.click();
99
+ return {
100
+ name,
101
+ uri
102
+ };
103
+ },
104
+ /** The desktop host contract is text-only, so bytes always download. */
105
+ async writeBytes(name, bytes) {
106
+ assertSafeFileName(name);
107
+ const uri = mintObjectUrl(new Blob([exactBuffer(bytes)], { type: "application/octet-stream" }));
108
+ const a = document.createElement("a");
109
+ a.href = uri;
110
+ a.download = name;
111
+ a.click();
112
+ return {
113
+ name,
114
+ uri
115
+ };
116
+ },
117
+ async export(name, bytes, opts) {
118
+ assertSafeFileName(name);
119
+ const picker = window.showSaveFilePicker;
120
+ if (typeof picker !== "function") return "unavailable";
121
+ const extension = name.includes(".") ? `.${name.split(".").pop()}` : null;
122
+ const types = opts?.mimeType && extension ? [{ accept: { [opts.mimeType]: [extension] } }] : void 0;
123
+ try {
124
+ const writable = await (await picker.call(window, {
125
+ suggestedName: name,
126
+ types
127
+ })).createWritable();
128
+ await writable.write(bytes);
129
+ await writable.close();
130
+ return "saved";
131
+ } catch (error) {
132
+ if (error?.name === "AbortError") return "cancelled";
133
+ throw error;
134
+ }
135
+ },
136
+ release(ref) {
137
+ if (ref.uri.startsWith("blob:")) {
138
+ objectBlobs.delete(ref.uri);
139
+ URL.revokeObjectURL(ref.uri);
140
+ }
141
+ }
142
+ };
143
+ //#endregion
144
+ export { files };
@@ -0,0 +1,5 @@
1
+ import { FileInput } from "./FileInput.web.js";
2
+ import "./FileInput.js";
3
+ import { files } from "./files.web.js";
4
+ import "./files.js";
5
+ export { FileInput, files };
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 1,
3
+ "buildId": "190c966d-ea82-4510-9cd2-4581c6451685",
4
+ "mode": "production",
5
+ "capabilities": {
6
+ "independentHydration": false
7
+ }
8
+ }
File without changes
File without changes